lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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:
MCargo.lock | 23+++++++++++++++++++++++
MCargo.toml | 6++++++
Mcontracts/coverage.toml | 3+++
Mcontracts/manifest.toml | 3+++
Mcontracts/version.toml | 1+
Acrates/mesh/Cargo.toml | 22++++++++++++++++++++++
Acrates/mesh/README | 20++++++++++++++++++++
Acrates/mesh/src/cbor.rs | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mesh/src/error.rs | 28++++++++++++++++++++++++++++
Acrates/mesh/src/lib.rs | 15+++++++++++++++
Acrates/mesh/src/model.rs | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mesh/tests/mesh.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mesh_agent_proto/Cargo.toml | 21+++++++++++++++++++++
Acrates/mesh_agent_proto/README | 23+++++++++++++++++++++++
Acrates/mesh_agent_proto/schema/radroots_mesh_agent_v1.capnp | 40++++++++++++++++++++++++++++++++++++++++
Acrates/mesh_agent_proto/src/lib.rs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mesh_agent_proto/tests/schema.rs | 36++++++++++++++++++++++++++++++++++++
Acrates/transport/Cargo.toml | 26++++++++++++++++++++++++++
Acrates/transport/README | 21+++++++++++++++++++++
Acrates/transport/src/delivery.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/transport/src/error.rs | 34++++++++++++++++++++++++++++++++++
Acrates/transport/src/kind.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/transport/src/lib.rs | 22++++++++++++++++++++++
Acrates/transport/src/status.rs | 40++++++++++++++++++++++++++++++++++++++++
Acrates/transport/src/target.rs | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/transport/tests/transport.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + ); +}