commit f9281482f2fb4ceb5d524044821cdcedfef8c540
parent ab1eb2941dee184bf7a8c2d01abf01f3f51bcd5e
Author: triesap <tyson@radroots.org>
Date: Mon, 6 Jul 2026 19:50:38 +0000
transport: add transport and mesh foundation crates
- add transport-neutral target, fingerprint, status, request, and receipt models
- add mesh scope, event-head, payload-policy, and deterministic CBOR frame contracts
- add mesh agent Capn Proto schema validation and digest surface
- update workspace contract, coverage, and version metadata for the new crates
Diffstat:
26 files changed, 1299 insertions(+), 0 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4387,6 +4387,20 @@ dependencies = [
]
[[package]]
+name = "radroots_mesh"
+version = "0.1.0-alpha.2"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "radroots_mesh_agent_proto"
+version = "0.1.0-alpha.2"
+dependencies = [
+ "sha2",
+]
+
+[[package]]
name = "radroots_net"
version = "0.1.0-alpha.2"
dependencies = [
@@ -4849,6 +4863,15 @@ dependencies = [
]
[[package]]
+name = "radroots_transport"
+version = "0.1.0-alpha.2"
+dependencies = [
+ "serde",
+ "serde_json",
+ "sha2",
+]
+
+[[package]]
name = "radroots_types"
version = "0.1.0-alpha.2"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
@@ -42,7 +42,10 @@ members = [
"crates/sp1_guest_trade",
"crates/sp1_host_trade",
"crates/trade",
+ "crates/transport",
"crates/types",
+ "crates/mesh",
+ "crates/mesh_agent_proto",
"crates/protected_store",
"tools/xtask",
]
@@ -98,7 +101,10 @@ radroots_replica_db_schema = { path = "crates/replica_db_schema", version = "0.1
radroots_replica_sync = { path = "crates/replica_sync", version = "0.1.0-alpha.2", default-features = false }
radroots_replica_db = { path = "crates/replica_db", version = "0.1.0-alpha.2", default-features = false }
radroots_trade = { path = "crates/trade", version = "0.1.0-alpha.2", default-features = false }
+radroots_transport = { path = "crates/transport", version = "0.1.0-alpha.2", default-features = false }
radroots_types = { path = "crates/types", version = "0.1.0-alpha.2", default-features = false }
+radroots_mesh = { path = "crates/mesh", version = "0.1.0-alpha.2", default-features = false }
+radroots_mesh_agent_proto = { path = "crates/mesh_agent_proto", version = "0.1.0-alpha.2", default-features = false }
radroots_protected_store = { path = "crates/protected_store", version = "0.1.0-alpha.2", default-features = false }
radroots_secret_vault = { path = "crates/secret_vault", version = "0.1.0-alpha.2", default-features = false }
radroots_simplex_app_store = { path = "crates/simplex_app_store", version = "0.1.0-alpha.2", default-features = false }
diff --git a/contracts/coverage.toml b/contracts/coverage.toml
@@ -41,6 +41,8 @@ crates = [
"radroots_identity",
"radroots_local_events",
"radroots_log",
+ "radroots_mesh",
+ "radroots_mesh_agent_proto",
"radroots_net",
"radroots_nostr",
"radroots_nostr_accounts",
@@ -65,6 +67,7 @@ crates = [
"radroots_sql_core",
"radroots_test_fixtures",
"radroots_trade",
+ "radroots_transport",
"radroots_types",
"xtask",
]
diff --git a/contracts/manifest.toml b/contracts/manifest.toml
@@ -46,6 +46,9 @@ deferred_publication = [
"radroots_outbox",
"radroots_publish_proxy_protocol",
"radroots_relay_transport",
+ "radroots_transport",
+ "radroots_mesh",
+ "radroots_mesh_agent_proto",
"radroots_net",
"radroots_nostr_runtime",
"radroots_nostr_ndb",
diff --git a/contracts/version.toml b/contracts/version.toml
@@ -17,6 +17,7 @@ minor_on = [
"add_enum_variant",
"add_conformance_vector",
"add_replica_transport_adapter",
+ "add_transport_foundation_crate",
]
patch_on = [
"fix_docs",
diff --git a/crates/mesh/Cargo.toml b/crates/mesh/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "radroots_mesh"
+publish = false
+version = "0.1.0-alpha.2"
+edition.workspace = true
+authors = ["Tyson Lupul <tyson@radroots.org>"]
+rust-version.workspace = true
+license.workspace = true
+description = "Mesh synchronization model for Radroots"
+repository.workspace = true
+homepage.workspace = true
+readme = "README"
+
+[features]
+default = ["serde"]
+serde = ["dep:serde"]
+
+[dependencies]
+serde = { workspace = true, optional = true }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/mesh/README b/crates/mesh/README
@@ -0,0 +1,20 @@
+# radroots_mesh
+
+This is the README for `radroots_mesh`, which provides mesh synchronization
+models for the `radroots` core libraries.
+
+## Overview
+
+ * mesh scope and payload policy model types;
+ * event-head synchronization metadata for mesh-aware peers;
+ * deterministic CBOR frame encode and decode helpers;
+ * MVP defaults that prevent mesh payload transmission.
+
+## Copyright
+
+Except as otherwise noted, all files in the `radroots_mesh` distribution are
+
+ Copyright (c) 2025 Tyson Lupul
+
+For information on usage and redistribution, and for a DISCLAIMER OF ALL
+WARRANTIES, see LICENSE included in the `radroots_mesh` distribution.
diff --git a/crates/mesh/src/cbor.rs b/crates/mesh/src/cbor.rs
@@ -0,0 +1,238 @@
+use crate::{
+ RADROOTS_MESH_FRAME_VERSION, RadrootsMeshError, RadrootsMeshEventHead, RadrootsMeshFrame,
+ RadrootsMeshPayloadPolicy, RadrootsMeshScope,
+};
+use alloc::string::String;
+use alloc::vec::Vec;
+
+pub fn encode_mesh_frame_cbor(frame: &RadrootsMeshFrame) -> Result<Vec<u8>, RadrootsMeshError> {
+ frame.validate()?;
+ let mut output = Vec::new();
+ encode_map_len(&mut output, 5);
+ encode_uint(&mut output, 1);
+ encode_uint(&mut output, u64::from(frame.version));
+ encode_uint(&mut output, 2);
+ encode_text(&mut output, &frame.scope.cbor_label())?;
+ encode_uint(&mut output, 3);
+ encode_text(&mut output, frame.payload_policy.label())?;
+ encode_uint(&mut output, 4);
+ encode_array_len(&mut output, frame.event_heads.len() as u64);
+ for head in &frame.event_heads {
+ encode_event_head(&mut output, head)?;
+ }
+ encode_uint(&mut output, 5);
+ output.push(0xf6);
+ Ok(output)
+}
+
+pub fn decode_mesh_frame_cbor(bytes: &[u8]) -> Result<RadrootsMeshFrame, RadrootsMeshError> {
+ let mut cursor = Cursor::new(bytes);
+ cursor.expect_map_len(5)?;
+ cursor.expect_uint(1)?;
+ let version = cursor.read_uint()? as u16;
+ if version != RADROOTS_MESH_FRAME_VERSION {
+ return Err(RadrootsMeshError::UnsupportedVersion);
+ }
+ cursor.expect_uint(2)?;
+ let scope = RadrootsMeshScope::parse(&cursor.read_text()?)?;
+ cursor.expect_uint(3)?;
+ let payload_policy = RadrootsMeshPayloadPolicy::parse(&cursor.read_text()?)?;
+ cursor.expect_uint(4)?;
+ let head_count = cursor.read_array_len()? as usize;
+ let mut event_heads = Vec::with_capacity(head_count);
+ for _ in 0..head_count {
+ event_heads.push(decode_event_head(&mut cursor)?);
+ }
+ cursor.expect_uint(5)?;
+ cursor.expect_null()?;
+ cursor.finish()?;
+ let frame = RadrootsMeshFrame {
+ version,
+ scope,
+ payload_policy,
+ event_heads,
+ payload: None,
+ };
+ frame.validate()?;
+ Ok(frame)
+}
+
+fn encode_event_head(
+ output: &mut Vec<u8>,
+ head: &RadrootsMeshEventHead,
+) -> Result<(), RadrootsMeshError> {
+ encode_map_len(output, 4);
+ encode_uint(output, 1);
+ encode_text(output, &head.event_id)?;
+ encode_uint(output, 2);
+ encode_text(output, &head.author)?;
+ encode_uint(output, 3);
+ encode_uint(output, u64::from(head.kind));
+ encode_uint(output, 4);
+ encode_uint(output, head.created_at);
+ Ok(())
+}
+
+fn decode_event_head(cursor: &mut Cursor<'_>) -> Result<RadrootsMeshEventHead, RadrootsMeshError> {
+ cursor.expect_map_len(4)?;
+ cursor.expect_uint(1)?;
+ let event_id = cursor.read_text()?;
+ cursor.expect_uint(2)?;
+ let author = cursor.read_text()?;
+ cursor.expect_uint(3)?;
+ let kind = cursor.read_uint()? as u32;
+ cursor.expect_uint(4)?;
+ let created_at = cursor.read_uint()?;
+ Ok(RadrootsMeshEventHead {
+ event_id,
+ author,
+ kind,
+ created_at,
+ })
+}
+
+fn encode_uint(output: &mut Vec<u8>, value: u64) {
+ encode_major(output, 0, value);
+}
+
+fn encode_text(output: &mut Vec<u8>, value: &str) -> Result<(), RadrootsMeshError> {
+ encode_major(output, 3, value.len() as u64);
+ output.extend_from_slice(value.as_bytes());
+ Ok(())
+}
+
+fn encode_array_len(output: &mut Vec<u8>, len: u64) {
+ encode_major(output, 4, len);
+}
+
+fn encode_map_len(output: &mut Vec<u8>, len: u64) {
+ encode_major(output, 5, len);
+}
+
+fn encode_major(output: &mut Vec<u8>, major: u8, value: u64) {
+ let prefix = major << 5;
+ match value {
+ 0..=23 => output.push(prefix | value as u8),
+ 24..=0xff => output.extend_from_slice(&[prefix | 24, value as u8]),
+ 0x100..=0xffff => {
+ output.push(prefix | 25);
+ output.extend_from_slice(&(value as u16).to_be_bytes());
+ }
+ 0x1_0000..=0xffff_ffff => {
+ output.push(prefix | 26);
+ output.extend_from_slice(&(value as u32).to_be_bytes());
+ }
+ _ => {
+ output.push(prefix | 27);
+ output.extend_from_slice(&value.to_be_bytes());
+ }
+ }
+}
+
+struct Cursor<'a> {
+ bytes: &'a [u8],
+ offset: usize,
+}
+
+impl<'a> Cursor<'a> {
+ fn new(bytes: &'a [u8]) -> Self {
+ Self { bytes, offset: 0 }
+ }
+
+ fn read_byte(&mut self) -> Result<u8, RadrootsMeshError> {
+ let byte = self
+ .bytes
+ .get(self.offset)
+ .copied()
+ .ok_or(RadrootsMeshError::InvalidCbor)?;
+ self.offset += 1;
+ Ok(byte)
+ }
+
+ fn read_exact(&mut self, len: usize) -> Result<&'a [u8], RadrootsMeshError> {
+ let end = self
+ .offset
+ .checked_add(len)
+ .ok_or(RadrootsMeshError::InvalidCbor)?;
+ let slice = self
+ .bytes
+ .get(self.offset..end)
+ .ok_or(RadrootsMeshError::InvalidCbor)?;
+ self.offset = end;
+ Ok(slice)
+ }
+
+ fn read_major(&mut self, expected_major: u8) -> Result<u64, RadrootsMeshError> {
+ let initial = self.read_byte()?;
+ if initial >> 5 != expected_major {
+ return Err(RadrootsMeshError::InvalidCbor);
+ }
+ match initial & 0x1f {
+ value @ 0..=23 => Ok(u64::from(value)),
+ 24 => Ok(u64::from(self.read_byte()?)),
+ 25 => {
+ let bytes = self.read_exact(2)?;
+ Ok(u64::from(u16::from_be_bytes([bytes[0], bytes[1]])))
+ }
+ 26 => {
+ let bytes = self.read_exact(4)?;
+ Ok(u64::from(u32::from_be_bytes([
+ bytes[0], bytes[1], bytes[2], bytes[3],
+ ])))
+ }
+ 27 => {
+ let bytes = self.read_exact(8)?;
+ Ok(u64::from_be_bytes([
+ bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
+ ]))
+ }
+ _ => Err(RadrootsMeshError::InvalidCbor),
+ }
+ }
+
+ fn read_uint(&mut self) -> Result<u64, RadrootsMeshError> {
+ self.read_major(0)
+ }
+
+ fn expect_uint(&mut self, expected: u64) -> Result<(), RadrootsMeshError> {
+ if self.read_uint()? == expected {
+ Ok(())
+ } else {
+ Err(RadrootsMeshError::InvalidCbor)
+ }
+ }
+
+ fn read_text(&mut self) -> Result<String, RadrootsMeshError> {
+ let len = self.read_major(3)? as usize;
+ let bytes = self.read_exact(len)?;
+ String::from_utf8(bytes.to_vec()).map_err(|_| RadrootsMeshError::InvalidUtf8)
+ }
+
+ fn read_array_len(&mut self) -> Result<u64, RadrootsMeshError> {
+ self.read_major(4)
+ }
+
+ fn expect_map_len(&mut self, expected: u64) -> Result<(), RadrootsMeshError> {
+ if self.read_major(5)? == expected {
+ Ok(())
+ } else {
+ Err(RadrootsMeshError::InvalidCbor)
+ }
+ }
+
+ fn expect_null(&mut self) -> Result<(), RadrootsMeshError> {
+ if self.read_byte()? == 0xf6 {
+ Ok(())
+ } else {
+ Err(RadrootsMeshError::InvalidCbor)
+ }
+ }
+
+ fn finish(&self) -> Result<(), RadrootsMeshError> {
+ if self.offset == self.bytes.len() {
+ Ok(())
+ } else {
+ Err(RadrootsMeshError::InvalidCbor)
+ }
+ }
+}
diff --git a/crates/mesh/src/error.rs b/crates/mesh/src/error.rs
@@ -0,0 +1,28 @@
+use core::fmt;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsMeshError {
+ EmptyCustomScope,
+ PayloadTransmissionForbidden,
+ InvalidCbor,
+ InvalidUtf8,
+ UnknownScope,
+ UnknownPayloadPolicy,
+ UnsupportedVersion,
+}
+
+impl fmt::Display for RadrootsMeshError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::EmptyCustomScope => f.write_str("mesh custom scope is empty"),
+ Self::PayloadTransmissionForbidden => {
+ f.write_str("mesh payload transmission is forbidden")
+ }
+ Self::InvalidCbor => f.write_str("mesh frame CBOR is invalid"),
+ Self::InvalidUtf8 => f.write_str("mesh frame text is invalid UTF-8"),
+ Self::UnknownScope => f.write_str("mesh scope is unknown"),
+ Self::UnknownPayloadPolicy => f.write_str("mesh payload policy is unknown"),
+ Self::UnsupportedVersion => f.write_str("mesh frame version is unsupported"),
+ }
+ }
+}
diff --git a/crates/mesh/src/lib.rs b/crates/mesh/src/lib.rs
@@ -0,0 +1,15 @@
+#![no_std]
+#![forbid(unsafe_code)]
+
+extern crate alloc;
+
+mod cbor;
+mod error;
+mod model;
+
+pub use cbor::{decode_mesh_frame_cbor, encode_mesh_frame_cbor};
+pub use error::RadrootsMeshError;
+pub use model::{
+ RADROOTS_MESH_FRAME_VERSION, RadrootsMeshEventHead, RadrootsMeshFrame,
+ RadrootsMeshPayloadPolicy, RadrootsMeshScope,
+};
diff --git a/crates/mesh/src/model.rs b/crates/mesh/src/model.rs
@@ -0,0 +1,114 @@
+use crate::RadrootsMeshError;
+use alloc::string::{String, ToString};
+use alloc::vec::Vec;
+
+pub const RADROOTS_MESH_FRAME_VERSION: u16 = 1;
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum RadrootsMeshScope {
+ Local,
+ Community,
+ Custom(String),
+}
+
+impl RadrootsMeshScope {
+ pub fn custom(value: impl Into<String>) -> Result<Self, RadrootsMeshError> {
+ let value = value.into();
+ let canonical = value.trim().to_ascii_lowercase();
+ if canonical.is_empty() {
+ return Err(RadrootsMeshError::EmptyCustomScope);
+ }
+ Ok(Self::Custom(canonical))
+ }
+
+ pub fn label(&self) -> &str {
+ match self {
+ Self::Local => "local",
+ Self::Community => "community",
+ Self::Custom(value) => value.as_str(),
+ }
+ }
+
+ pub fn parse(value: &str) -> Result<Self, RadrootsMeshError> {
+ match value {
+ "local" => Ok(Self::Local),
+ "community" => Ok(Self::Community),
+ value if value.starts_with("custom:") => Self::custom(&value["custom:".len()..]),
+ _ => Err(RadrootsMeshError::UnknownScope),
+ }
+ }
+
+ pub fn cbor_label(&self) -> String {
+ match self {
+ Self::Custom(value) => {
+ let mut label = String::from("custom:");
+ label.push_str(value);
+ label
+ }
+ _ => self.label().to_string(),
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum RadrootsMeshPayloadPolicy {
+ PayloadTransmissionForbidden,
+}
+
+impl RadrootsMeshPayloadPolicy {
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::PayloadTransmissionForbidden => "payload-forbidden",
+ }
+ }
+
+ pub fn parse(value: &str) -> Result<Self, RadrootsMeshError> {
+ match value {
+ "payload-forbidden" => Ok(Self::PayloadTransmissionForbidden),
+ _ => Err(RadrootsMeshError::UnknownPayloadPolicy),
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsMeshEventHead {
+ pub event_id: String,
+ pub author: String,
+ pub kind: u32,
+ pub created_at: u64,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsMeshFrame {
+ pub version: u16,
+ pub scope: RadrootsMeshScope,
+ pub payload_policy: RadrootsMeshPayloadPolicy,
+ pub event_heads: Vec<RadrootsMeshEventHead>,
+ pub payload: Option<Vec<u8>>,
+}
+
+impl RadrootsMeshFrame {
+ pub fn new(scope: RadrootsMeshScope, event_heads: Vec<RadrootsMeshEventHead>) -> Self {
+ Self {
+ version: RADROOTS_MESH_FRAME_VERSION,
+ scope,
+ payload_policy: RadrootsMeshPayloadPolicy::PayloadTransmissionForbidden,
+ event_heads,
+ payload: None,
+ }
+ }
+
+ pub fn validate(&self) -> Result<(), RadrootsMeshError> {
+ if self.version != RADROOTS_MESH_FRAME_VERSION {
+ return Err(RadrootsMeshError::UnsupportedVersion);
+ }
+ if self.payload.is_some() {
+ return Err(RadrootsMeshError::PayloadTransmissionForbidden);
+ }
+ Ok(())
+ }
+}
diff --git a/crates/mesh/tests/mesh.rs b/crates/mesh/tests/mesh.rs
@@ -0,0 +1,63 @@
+use radroots_mesh::{
+ RadrootsMeshError, RadrootsMeshEventHead, RadrootsMeshFrame, RadrootsMeshPayloadPolicy,
+ RadrootsMeshScope, decode_mesh_frame_cbor, encode_mesh_frame_cbor,
+};
+
+#[test]
+fn default_frame_encodes_as_deterministic_cbor() {
+ let frame = RadrootsMeshFrame::new(RadrootsMeshScope::Local, Vec::new());
+ let encoded = encode_mesh_frame_cbor(&frame).expect("encode frame");
+
+ assert_eq!(
+ encoded,
+ [
+ 0xa5, 0x01, 0x01, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x71, b'p', b'a',
+ b'y', b'l', b'o', b'a', b'd', b'-', b'f', b'o', b'r', b'b', b'i', b'd', b'd', b'e',
+ b'n', 0x04, 0x80, 0x05, 0xf6,
+ ]
+ );
+ assert_eq!(
+ decode_mesh_frame_cbor(&encoded).expect("decode frame"),
+ frame
+ );
+}
+
+#[test]
+fn event_head_frames_round_trip() {
+ let frame = RadrootsMeshFrame::new(
+ RadrootsMeshScope::Community,
+ vec![RadrootsMeshEventHead {
+ event_id: "event-1".to_string(),
+ author: "author-1".to_string(),
+ kind: 30818,
+ created_at: 1_725_000_000,
+ }],
+ );
+ let encoded = encode_mesh_frame_cbor(&frame).expect("encode frame");
+ let decoded = decode_mesh_frame_cbor(&encoded).expect("decode frame");
+
+ assert_eq!(decoded, frame);
+ assert_eq!(encode_mesh_frame_cbor(&decoded).expect("reencode"), encoded);
+}
+
+#[test]
+fn payload_transmission_is_forbidden_in_mvp_frames() {
+ let mut frame = RadrootsMeshFrame::new(RadrootsMeshScope::Local, Vec::new());
+ frame.payload_policy = RadrootsMeshPayloadPolicy::PayloadTransmissionForbidden;
+ frame.payload = Some(vec![1, 2, 3]);
+
+ assert_eq!(
+ encode_mesh_frame_cbor(&frame).expect_err("payload must fail"),
+ RadrootsMeshError::PayloadTransmissionForbidden
+ );
+}
+
+#[test]
+fn custom_scope_has_explicit_namespace() {
+ let scope = RadrootsMeshScope::custom(" Farm-North ").expect("custom scope");
+ let frame = RadrootsMeshFrame::new(scope, Vec::new());
+ let encoded = encode_mesh_frame_cbor(&frame).expect("encode custom scope");
+ let decoded = decode_mesh_frame_cbor(&encoded).expect("decode custom scope");
+
+ assert_eq!(decoded.scope.cbor_label(), "custom:farm-north");
+}
diff --git a/crates/mesh_agent_proto/Cargo.toml b/crates/mesh_agent_proto/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "radroots_mesh_agent_proto"
+publish = false
+version = "0.1.0-alpha.2"
+edition.workspace = true
+authors = ["Tyson Lupul <tyson@radroots.org>"]
+rust-version.workspace = true
+license.workspace = true
+description = "Mesh agent protocol schema for Radroots"
+repository.workspace = true
+homepage.workspace = true
+readme = "README"
+
+[features]
+default = []
+
+[dependencies]
+sha2 = { workspace = true, default-features = false }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/mesh_agent_proto/README b/crates/mesh_agent_proto/README
@@ -0,0 +1,23 @@
+# radroots_mesh_agent_proto
+
+This is the README for `radroots_mesh_agent_proto`, which provides the mesh
+agent protocol schema surface for the `radroots` core libraries.
+
+## Overview
+
+ * Cap'n Proto schema text for the future `radroots.mesh_agent.v1` IPC surface;
+ * schema id and deterministic SHA-256 digest helpers;
+ * narrow validation helpers for required request, response, event, and error
+ schema declarations;
+ * schema-only protocol posture without an agent runtime or IPC client.
+
+## Copyright
+
+Except as otherwise noted, all files in the `radroots_mesh_agent_proto`
+distribution are
+
+ Copyright (c) 2025 Tyson Lupul
+
+For information on usage and redistribution, and for a DISCLAIMER OF ALL
+WARRANTIES, see LICENSE included in the `radroots_mesh_agent_proto`
+distribution.
diff --git a/crates/mesh_agent_proto/schema/radroots_mesh_agent_v1.capnp b/crates/mesh_agent_proto/schema/radroots_mesh_agent_v1.capnp
@@ -0,0 +1,40 @@
+@0xb83e0c4f71838d9a;
+
+using Cxx = import "/capnp/c++.capnp";
+
+$Cxx.namespace("radroots::mesh_agent::v1");
+
+struct MeshAgentRequest {
+ requestId @0 :Text;
+ action @1 :MeshAgentAction;
+ frameCbor @2 :Data;
+}
+
+enum MeshAgentAction {
+ validateFrame @0;
+ stageDelivery @1;
+ observeEventHead @2;
+}
+
+struct MeshAgentResponse {
+ requestId @0 :Text;
+ status @1 :MeshAgentResponseStatus;
+ receipt @2 :MeshAgentReceipt;
+ errors @3 :List(MeshAgentError);
+}
+
+enum MeshAgentResponseStatus {
+ accepted @0;
+ deferred @1;
+ rejected @2;
+}
+
+struct MeshAgentReceipt {
+ frameDigest @0 :Text;
+ acceptedEventHeads @1 :List(Text);
+}
+
+struct MeshAgentError {
+ code @0 :Text;
+ message @1 :Text;
+}
diff --git a/crates/mesh_agent_proto/src/lib.rs b/crates/mesh_agent_proto/src/lib.rs
@@ -0,0 +1,90 @@
+#![no_std]
+#![forbid(unsafe_code)]
+
+extern crate alloc;
+
+use alloc::string::String;
+use core::fmt;
+use sha2::{Digest, Sha256};
+
+pub const RADROOTS_MESH_AGENT_SCHEMA_ID: &str = "0xb83e0c4f71838d9a";
+pub const RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE: &str = "radroots::mesh_agent::v1";
+pub const RADROOTS_MESH_AGENT_SCHEMA: &str = include_str!("../schema/radroots_mesh_agent_v1.capnp");
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsMeshAgentProtoError {
+ MissingSchemaId,
+ MissingNamespace,
+ MissingRequest,
+ MissingAction,
+ MissingResponse,
+ MissingReceipt,
+ MissingError,
+}
+
+impl fmt::Display for RadrootsMeshAgentProtoError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::MissingSchemaId => f.write_str("mesh agent schema id is missing"),
+ Self::MissingNamespace => f.write_str("mesh agent schema namespace is missing"),
+ Self::MissingRequest => f.write_str("mesh agent request schema is missing"),
+ Self::MissingAction => f.write_str("mesh agent action schema is missing"),
+ Self::MissingResponse => f.write_str("mesh agent response schema is missing"),
+ Self::MissingReceipt => f.write_str("mesh agent receipt schema is missing"),
+ Self::MissingError => f.write_str("mesh agent error schema is missing"),
+ }
+ }
+}
+
+pub fn schema_sha256_hex() -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(RADROOTS_MESH_AGENT_SCHEMA.as_bytes());
+ hex_encode(&hasher.finalize())
+}
+
+fn hex_encode(bytes: &[u8]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut out = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ out.push(HEX[(byte >> 4) as usize] as char);
+ out.push(HEX[(byte & 0x0f) as usize] as char);
+ }
+ out
+}
+
+pub fn validate_schema() -> Result<(), RadrootsMeshAgentProtoError> {
+ validate_schema_text(RADROOTS_MESH_AGENT_SCHEMA)
+}
+
+pub fn validate_schema_text(schema: &str) -> Result<(), RadrootsMeshAgentProtoError> {
+ if !schema.contains(RADROOTS_MESH_AGENT_SCHEMA_ID) {
+ return Err(RadrootsMeshAgentProtoError::MissingSchemaId);
+ }
+ if !schema.contains(RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE) {
+ return Err(RadrootsMeshAgentProtoError::MissingNamespace);
+ }
+ if !schema.contains("struct MeshAgentRequest") || !schema.contains("frameCbor @2 :Data;") {
+ return Err(RadrootsMeshAgentProtoError::MissingRequest);
+ }
+ if !schema.contains("enum MeshAgentAction")
+ || !schema.contains("validateFrame @0;")
+ || !schema.contains("stageDelivery @1;")
+ || !schema.contains("observeEventHead @2;")
+ {
+ return Err(RadrootsMeshAgentProtoError::MissingAction);
+ }
+ if !schema.contains("struct MeshAgentResponse")
+ || !schema.contains("enum MeshAgentResponseStatus")
+ {
+ return Err(RadrootsMeshAgentProtoError::MissingResponse);
+ }
+ if !schema.contains("struct MeshAgentReceipt")
+ || !schema.contains("acceptedEventHeads @1 :List(Text);")
+ {
+ return Err(RadrootsMeshAgentProtoError::MissingReceipt);
+ }
+ if !schema.contains("struct MeshAgentError") {
+ return Err(RadrootsMeshAgentProtoError::MissingError);
+ }
+ Ok(())
+}
diff --git a/crates/mesh_agent_proto/tests/schema.rs b/crates/mesh_agent_proto/tests/schema.rs
@@ -0,0 +1,36 @@
+use radroots_mesh_agent_proto::{
+ RADROOTS_MESH_AGENT_SCHEMA, RADROOTS_MESH_AGENT_SCHEMA_ID,
+ RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE, RadrootsMeshAgentProtoError, schema_sha256_hex,
+ validate_schema, validate_schema_text,
+};
+
+#[test]
+fn schema_declares_mesh_agent_v1_surface() {
+ validate_schema().expect("schema validates");
+
+ assert!(RADROOTS_MESH_AGENT_SCHEMA.contains(RADROOTS_MESH_AGENT_SCHEMA_ID));
+ assert!(RADROOTS_MESH_AGENT_SCHEMA.contains(RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE));
+ assert!(RADROOTS_MESH_AGENT_SCHEMA.contains("struct MeshAgentRequest"));
+ assert!(RADROOTS_MESH_AGENT_SCHEMA.contains("struct MeshAgentResponse"));
+}
+
+#[test]
+fn schema_hash_is_deterministic() {
+ let first = schema_sha256_hex();
+ let second = schema_sha256_hex();
+
+ assert_eq!(first, second);
+ assert_eq!(
+ first,
+ "ceaaa2968805a21c9f08b76b22ac45ae819312ee595a60675f560ec81d6f8fe0"
+ );
+}
+
+#[test]
+fn schema_validator_rejects_missing_required_surface() {
+ assert_eq!(
+ validate_schema_text("@0xb83e0c4f71838d9a;")
+ .expect_err("schema missing namespace and structs"),
+ RadrootsMeshAgentProtoError::MissingNamespace
+ );
+}
diff --git a/crates/transport/Cargo.toml b/crates/transport/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "radroots_transport"
+publish = false
+version = "0.1.0-alpha.2"
+edition.workspace = true
+authors = ["Tyson Lupul <tyson@radroots.org>"]
+rust-version.workspace = true
+license.workspace = true
+description = "Transport-neutral delivery model for Radroots"
+repository.workspace = true
+homepage.workspace = true
+readme = "README"
+
+[features]
+default = ["serde"]
+serde = ["dep:serde"]
+
+[dependencies]
+serde = { workspace = true, optional = true }
+sha2 = { workspace = true, default-features = false }
+
+[dev-dependencies]
+serde_json = { workspace = true, features = ["std"] }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/transport/README b/crates/transport/README
@@ -0,0 +1,21 @@
+# radroots_transport
+
+This is the README for `radroots_transport`, which provides transport-neutral
+delivery models for the `radroots` core libraries.
+
+## Overview
+
+ * transport kind, implementation state, and status model types;
+ * target URI, target fingerprint, target set, request, and receipt contracts;
+ * deterministic target fingerprints based on transport kind and canonical
+ target URI;
+ * portable shared models with optional `serde` support.
+
+## Copyright
+
+Except as otherwise noted, all files in the `radroots_transport` distribution are
+
+ Copyright (c) 2025 Tyson Lupul
+
+For information on usage and redistribution, and for a DISCLAIMER OF ALL
+WARRANTIES, see LICENSE included in the `radroots_transport` distribution.
diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs
@@ -0,0 +1,101 @@
+use crate::{
+ RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportOutcome,
+ RadrootsTransportTarget, RadrootsTransportTargetSet,
+};
+use alloc::string::String;
+use alloc::vec::Vec;
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsTransportSatisfactionPolicy {
+ AllTargets,
+ AnyTarget,
+ AtLeast(u16),
+}
+
+impl RadrootsTransportSatisfactionPolicy {
+ pub fn required_target_count(
+ &self,
+ total_targets: usize,
+ ) -> Result<usize, RadrootsTransportError> {
+ if total_targets == 0 {
+ return Err(RadrootsTransportError::InvalidSatisfactionPolicy);
+ }
+ match self {
+ Self::AllTargets => Ok(total_targets),
+ Self::AnyTarget => Ok(1),
+ Self::AtLeast(count) if *count > 0 && usize::from(*count) <= total_targets => {
+ Ok(usize::from(*count))
+ }
+ Self::AtLeast(_) => Err(RadrootsTransportError::InvalidSatisfactionPolicy),
+ }
+ }
+
+ pub fn is_satisfied_by(
+ &self,
+ total_targets: usize,
+ satisfied_targets: usize,
+ ) -> Result<bool, RadrootsTransportError> {
+ let required = self.required_target_count(total_targets)?;
+ Ok(satisfied_targets >= required)
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTransportDeliveryRequest {
+ pub request_id: String,
+ pub payload_digest: String,
+ pub target_set: RadrootsTransportTargetSet,
+ pub satisfaction_policy: RadrootsTransportSatisfactionPolicy,
+}
+
+impl RadrootsTransportDeliveryRequest {
+ pub fn new(
+ request_id: impl Into<String>,
+ payload_digest: impl Into<String>,
+ target_set: RadrootsTransportTargetSet,
+ satisfaction_policy: RadrootsTransportSatisfactionPolicy,
+ ) -> Self {
+ Self {
+ request_id: request_id.into(),
+ payload_digest: payload_digest.into(),
+ target_set,
+ satisfaction_policy,
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTransportTargetReceipt {
+ pub target: RadrootsTransportTarget,
+ pub status: RadrootsTransportDeliveryTargetStatus,
+ pub outcome: RadrootsTransportOutcome,
+}
+
+impl RadrootsTransportTargetReceipt {
+ pub fn new(target: RadrootsTransportTarget, outcome: RadrootsTransportOutcome) -> Self {
+ Self {
+ target,
+ status: outcome.status,
+ outcome,
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTransportDeliveryReceipt {
+ pub request_id: String,
+ pub target_receipts: Vec<RadrootsTransportTargetReceipt>,
+}
+
+impl RadrootsTransportDeliveryReceipt {
+ pub fn satisfied_target_count(&self) -> usize {
+ self.target_receipts
+ .iter()
+ .filter(|receipt| receipt.status.counts_as_satisfied())
+ .count()
+ }
+}
diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs
@@ -0,0 +1,34 @@
+use core::fmt;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsTransportError {
+ EmptyTransportKind,
+ InvalidTransportKind,
+ EmptyTargetUri,
+ InvalidTargetUri,
+ EmptyTargetSet,
+ DuplicateTargetFingerprint,
+ InvalidTargetFingerprint,
+ InvalidSatisfactionPolicy,
+}
+
+impl fmt::Display for RadrootsTransportError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::EmptyTransportKind => f.write_str("transport kind is empty"),
+ Self::InvalidTransportKind => f.write_str("transport kind is invalid"),
+ Self::EmptyTargetUri => f.write_str("transport target URI is empty"),
+ Self::InvalidTargetUri => f.write_str("transport target URI is invalid"),
+ Self::EmptyTargetSet => f.write_str("transport target set is empty"),
+ Self::DuplicateTargetFingerprint => {
+ f.write_str("transport target set contains duplicate fingerprints")
+ }
+ Self::InvalidTargetFingerprint => {
+ f.write_str("transport target fingerprint is invalid")
+ }
+ Self::InvalidSatisfactionPolicy => {
+ f.write_str("transport satisfaction policy is invalid")
+ }
+ }
+ }
+}
diff --git a/crates/transport/src/kind.rs b/crates/transport/src/kind.rs
@@ -0,0 +1,48 @@
+use crate::RadrootsTransportError;
+use alloc::string::{String, ToString};
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum RadrootsTransportKind {
+ Nostr,
+ Reticulum,
+ Mesh,
+ Local,
+ Custom(String),
+}
+
+impl RadrootsTransportKind {
+ pub fn custom(value: impl Into<String>) -> Result<Self, RadrootsTransportError> {
+ let value = value.into();
+ let canonical = value.trim().to_ascii_lowercase();
+ if canonical.is_empty() {
+ return Err(RadrootsTransportError::EmptyTransportKind);
+ }
+ if canonical
+ .chars()
+ .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace() || ch == ':' || ch == '/')
+ {
+ return Err(RadrootsTransportError::InvalidTransportKind);
+ }
+ Ok(Self::Custom(canonical))
+ }
+
+ pub fn canonical_label(&self) -> String {
+ match self {
+ Self::Nostr => "nostr".to_string(),
+ Self::Reticulum => "reticulum".to_string(),
+ Self::Mesh => "mesh".to_string(),
+ Self::Local => "local".to_string(),
+ Self::Custom(value) => value.clone(),
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum RadrootsTransportImplementationState {
+ Available,
+ Disabled,
+ Misconfigured,
+ PreviewUnavailable,
+}
diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs
@@ -0,0 +1,22 @@
+#![no_std]
+#![forbid(unsafe_code)]
+
+extern crate alloc;
+
+mod delivery;
+mod error;
+mod kind;
+mod status;
+mod target;
+
+pub use delivery::{
+ RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
+ RadrootsTransportSatisfactionPolicy, RadrootsTransportTargetReceipt,
+};
+pub use error::RadrootsTransportError;
+pub use kind::{RadrootsTransportImplementationState, RadrootsTransportKind};
+pub use status::{RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome};
+pub use target::{
+ RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetSet,
+ RadrootsTransportTargetUri,
+};
diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs
@@ -0,0 +1,40 @@
+use alloc::string::String;
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum RadrootsTransportDeliveryTargetStatus {
+ Pending,
+ Accepted,
+ Deferred,
+ Rejected,
+ Failed,
+ Unavailable,
+}
+
+impl RadrootsTransportDeliveryTargetStatus {
+ pub fn is_terminal(self) -> bool {
+ !matches!(self, Self::Pending)
+ }
+
+ pub fn counts_as_satisfied(self) -> bool {
+ matches!(self, Self::Accepted | Self::Deferred)
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTransportOutcome {
+ pub status: RadrootsTransportDeliveryTargetStatus,
+ pub code: Option<String>,
+ pub message: Option<String>,
+}
+
+impl RadrootsTransportOutcome {
+ pub fn new(status: RadrootsTransportDeliveryTargetStatus) -> Self {
+ Self {
+ status,
+ code: None,
+ message: None,
+ }
+ }
+}
diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs
@@ -0,0 +1,167 @@
+use crate::{RadrootsTransportError, RadrootsTransportKind};
+use alloc::collections::BTreeSet;
+use alloc::format;
+use alloc::string::{String, ToString};
+use alloc::vec::Vec;
+use sha2::{Digest, Sha256};
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsTransportTargetUri(String);
+
+impl RadrootsTransportTargetUri {
+ pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> {
+ let canonical = canonicalize_uri(raw.as_ref())?;
+ Ok(Self(canonical))
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl core::fmt::Display for RadrootsTransportTargetUri {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsTransportTargetFingerprint(String);
+
+impl RadrootsTransportTargetFingerprint {
+ pub fn from_target(kind: &RadrootsTransportKind, uri: &RadrootsTransportTargetUri) -> Self {
+ let mut hasher = Sha256::new();
+ hasher.update(kind.canonical_label().as_bytes());
+ hasher.update([0]);
+ hasher.update(uri.as_str().as_bytes());
+ let digest = hasher.finalize();
+ Self(hex_encode(&digest))
+ }
+
+ pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> {
+ let raw = raw.as_ref();
+ if raw.len() != 64 || !raw.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(RadrootsTransportError::InvalidTargetFingerprint);
+ }
+ Ok(Self(raw.to_ascii_lowercase()))
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+fn hex_encode(bytes: &[u8]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut out = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ out.push(HEX[(byte >> 4) as usize] as char);
+ out.push(HEX[(byte & 0x0f) as usize] as char);
+ }
+ out
+}
+
+impl core::fmt::Display for RadrootsTransportTargetFingerprint {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTransportTarget {
+ pub kind: RadrootsTransportKind,
+ pub uri: RadrootsTransportTargetUri,
+ pub fingerprint: RadrootsTransportTargetFingerprint,
+}
+
+impl RadrootsTransportTarget {
+ pub fn new(
+ kind: RadrootsTransportKind,
+ uri: impl AsRef<str>,
+ ) -> Result<Self, RadrootsTransportError> {
+ let uri = RadrootsTransportTargetUri::parse(uri)?;
+ let fingerprint = RadrootsTransportTargetFingerprint::from_target(&kind, &uri);
+ Ok(Self {
+ kind,
+ uri,
+ fingerprint,
+ })
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTransportTargetSet {
+ targets: Vec<RadrootsTransportTarget>,
+}
+
+impl RadrootsTransportTargetSet {
+ pub fn new(targets: Vec<RadrootsTransportTarget>) -> Result<Self, RadrootsTransportError> {
+ if targets.is_empty() {
+ return Err(RadrootsTransportError::EmptyTargetSet);
+ }
+ let mut fingerprints = BTreeSet::new();
+ for target in &targets {
+ if !fingerprints.insert(target.fingerprint.as_str().to_string()) {
+ return Err(RadrootsTransportError::DuplicateTargetFingerprint);
+ }
+ }
+ Ok(Self { targets })
+ }
+
+ pub fn targets(&self) -> &[RadrootsTransportTarget] {
+ &self.targets
+ }
+
+ pub fn len(&self) -> usize {
+ self.targets.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.targets.is_empty()
+ }
+}
+
+fn canonicalize_uri(raw: &str) -> Result<String, RadrootsTransportError> {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() {
+ return Err(RadrootsTransportError::EmptyTargetUri);
+ }
+ if trimmed
+ .chars()
+ .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
+ {
+ return Err(RadrootsTransportError::InvalidTargetUri);
+ }
+ if let Some(colon) = trimmed.find(':') {
+ let scheme = &trimmed[..colon];
+ if !is_valid_scheme(scheme) {
+ return Err(RadrootsTransportError::InvalidTargetUri);
+ }
+ let rest = &trimmed[colon + 1..];
+ if let Some(authority_rest) = rest.strip_prefix("//") {
+ let authority_end = authority_rest
+ .find(['/', '?', '#'])
+ .unwrap_or(authority_rest.len());
+ let authority = &authority_rest[..authority_end];
+ let suffix = &authority_rest[authority_end..];
+ return Ok(format!(
+ "{}://{}{}",
+ scheme.to_ascii_lowercase(),
+ authority.to_ascii_lowercase(),
+ suffix
+ ));
+ }
+ return Ok(format!("{}:{rest}", scheme.to_ascii_lowercase()));
+ }
+ Ok(trimmed.to_string())
+}
+
+fn is_valid_scheme(value: &str) -> bool {
+ let mut chars = value.chars();
+ matches!(chars.next(), Some(first) if first.is_ascii_alphabetic())
+ && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
+}
diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs
@@ -0,0 +1,94 @@
+use radroots_transport::{
+ RadrootsTransportDeliveryRequest, RadrootsTransportError, RadrootsTransportKind,
+ RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget,
+ RadrootsTransportTargetFingerprint, RadrootsTransportTargetSet, RadrootsTransportTargetUri,
+};
+
+#[test]
+fn target_fingerprints_are_stable_and_transport_scoped() {
+ let nostr_upper =
+ RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, " WSS://Relay.Example/Events ")
+ .expect("nostr target");
+ let nostr_lower =
+ RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example/Events")
+ .expect("nostr target");
+ let reticulum = RadrootsTransportTarget::new(
+ RadrootsTransportKind::Reticulum,
+ "wss://relay.example/Events",
+ )
+ .expect("reticulum target");
+
+ assert_eq!(nostr_upper.uri.as_str(), "wss://relay.example/Events");
+ assert_eq!(nostr_upper.fingerprint, nostr_lower.fingerprint);
+ assert_ne!(nostr_upper.fingerprint, reticulum.fingerprint);
+ assert_eq!(
+ nostr_upper.fingerprint.as_str(),
+ "d0903c3067150d7b4f7efd92a9be002b97d74e83f8bb6827327fa7ecd869332b"
+ );
+}
+
+#[test]
+fn target_set_rejects_duplicate_fingerprints() {
+ let first = RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example/a")
+ .expect("first target");
+ let duplicate =
+ RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "WSS://RELAY.EXAMPLE/a")
+ .expect("duplicate target");
+ let err = RadrootsTransportTargetSet::new(vec![first, duplicate])
+ .expect_err("duplicate fingerprints must fail");
+
+ assert_eq!(err, RadrootsTransportError::DuplicateTargetFingerprint);
+}
+
+#[test]
+fn satisfaction_policy_counts_target_statuses() {
+ let all = RadrootsTransportSatisfactionPolicy::AllTargets;
+ let any = RadrootsTransportSatisfactionPolicy::AnyTarget;
+ let two = RadrootsTransportSatisfactionPolicy::AtLeast(2);
+
+ assert!(all.is_satisfied_by(2, 2).expect("all"));
+ assert!(!all.is_satisfied_by(2, 1).expect("all incomplete"));
+ assert!(any.is_satisfied_by(3, 1).expect("any"));
+ assert!(two.is_satisfied_by(3, 2).expect("two"));
+ assert_eq!(
+ any.is_satisfied_by(0, 0).expect_err("zero target set"),
+ RadrootsTransportError::InvalidSatisfactionPolicy
+ );
+ assert_eq!(
+ RadrootsTransportSatisfactionPolicy::AtLeast(0)
+ .is_satisfied_by(3, 0)
+ .expect_err("zero required targets"),
+ RadrootsTransportError::InvalidSatisfactionPolicy
+ );
+}
+
+#[test]
+fn request_models_round_trip_with_serde() {
+ let target = RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example")
+ .expect("target");
+ let target_set = RadrootsTransportTargetSet::new(vec![target]).expect("target set");
+ let request = RadrootsTransportDeliveryRequest::new(
+ "req-1",
+ "sha256:payload",
+ target_set,
+ RadrootsTransportSatisfactionPolicy::AnyTarget,
+ );
+
+ let json = serde_json::to_string(&request).expect("serialize request");
+ let decoded: RadrootsTransportDeliveryRequest =
+ serde_json::from_str(&json).expect("decode request");
+
+ assert_eq!(decoded, request);
+}
+
+#[test]
+fn fingerprint_parser_rejects_non_sha256_hex() {
+ assert_eq!(
+ RadrootsTransportTargetFingerprint::parse("abc").expect_err("short fingerprint"),
+ RadrootsTransportError::InvalidTargetFingerprint
+ );
+ assert_eq!(
+ RadrootsTransportTargetUri::parse("wss://relay example").expect_err("space in target uri"),
+ RadrootsTransportError::InvalidTargetUri
+ );
+}