diff --git a/crates/quicprochat-p2p/src/fapp_router.rs b/crates/quicprochat-p2p/src/fapp_router.rs index 598c60e..ffd342b 100644 --- a/crates/quicprochat-p2p/src/fapp_router.rs +++ b/crates/quicprochat-p2p/src/fapp_router.rs @@ -35,6 +35,21 @@ pub const FAPP_WIRE_RESERVE: u8 = 0x04; /// [`SlotConfirm`](crate::fapp::SlotConfirm) frame (handled later). pub const FAPP_WIRE_CONFIRM: u8 = 0x05; +/// Check whether a raw payload starts with a known FAPP wire tag. +/// +/// Useful for the mesh router to decide whether a delivered envelope should be +/// routed through the [`FappRouter`] rather than the application layer. +pub fn is_fapp_payload(payload: &[u8]) -> bool { + matches!( + payload.first(), + Some(&FAPP_WIRE_ANNOUNCE) + | Some(&FAPP_WIRE_QUERY) + | Some(&FAPP_WIRE_RESPONSE) + | Some(&FAPP_WIRE_RESERVE) + | Some(&FAPP_WIRE_CONFIRM) + ) +} + // --------------------------------------------------------------------------- // FappAction — what to do after handling an incoming FAPP frame // --------------------------------------------------------------------------- @@ -455,6 +470,24 @@ mod tests { use crate::fapp::{Fachrichtung, Kostentraeger, Modalitaet, SlotType, TimeSlot}; use crate::identity::MeshIdentity; + #[test] + fn is_fapp_payload_recognizes_all_tags() { + assert!(is_fapp_payload(&[FAPP_WIRE_ANNOUNCE, 0x01])); + assert!(is_fapp_payload(&[FAPP_WIRE_QUERY, 0x01])); + assert!(is_fapp_payload(&[FAPP_WIRE_RESPONSE, 0x01])); + assert!(is_fapp_payload(&[FAPP_WIRE_RESERVE, 0x01])); + assert!(is_fapp_payload(&[FAPP_WIRE_CONFIRM, 0x01])); + } + + #[test] + fn is_fapp_payload_rejects_non_fapp() { + assert!(!is_fapp_payload(&[])); + assert!(!is_fapp_payload(&[0x00])); + assert!(!is_fapp_payload(&[0x06])); + assert!(!is_fapp_payload(&[0x10])); // KeyPackageRequest tag + assert!(!is_fapp_payload(&[0xFF])); + } + #[test] fn handle_incoming_unknown_tag_dropped() { let routes = Arc::new(RwLock::new(RoutingTable::new(Duration::from_secs(300)))); diff --git a/crates/quicprochat-p2p/src/mesh_node.rs b/crates/quicprochat-p2p/src/mesh_node.rs index f9ca925..8dc78d9 100644 --- a/crates/quicprochat-p2p/src/mesh_node.rs +++ b/crates/quicprochat-p2p/src/mesh_node.rs @@ -22,7 +22,7 @@ use crate::config::MeshConfig; use crate::envelope::MeshEnvelope; use crate::error::{MeshError, MeshResult}; use crate::fapp::{FappStore, CAP_FAPP_PATIENT, CAP_FAPP_RELAY, CAP_FAPP_THERAPIST}; -use crate::fapp_router::FappRouter; +use crate::fapp_router::{is_fapp_payload, FappRouter}; use crate::identity::MeshIdentity; use crate::mesh_router::{IncomingAction, MeshRouter}; use crate::metrics::{self, MeshMetrics}; @@ -336,6 +336,17 @@ impl MeshNode { let action = self.mesh_router.handle_incoming(envelope) .map_err(|e| MeshError::Internal(e.to_string()))?; + // If the envelope is delivered locally and its payload is a FAPP frame, + // delegate to the FappRouter instead of returning a raw Deliver. + let action = match action { + IncomingAction::Deliver(ref env) if self.fapp_router.is_some() && is_fapp_payload(&env.payload) => { + let fapp_router = self.fapp_router.as_ref().unwrap(); + let fapp_action = fapp_router.handle_incoming(&env.payload); + IncomingAction::Fapp(fapp_action) + } + other => other, + }; + // Update routing metrics based on action match &action { IncomingAction::Deliver(_) => { @@ -350,6 +361,9 @@ impl MeshNode { IncomingAction::Dropped(_) => { self.metrics.protocol.parse_errors.inc(); } + IncomingAction::Fapp(_) => { + self.metrics.store.messages_delivered.inc(); + } } Ok(action) @@ -455,6 +469,8 @@ pub struct GcStats { #[cfg(test)] mod tests { use super::*; + use crate::envelope::MeshEnvelope; + use crate::fapp_router::{FappAction, FAPP_WIRE_QUERY, FAPP_WIRE_ANNOUNCE}; #[tokio::test] async fn mesh_node_starts() { @@ -526,4 +542,112 @@ mod tests { node.shutdown().await; } + + #[tokio::test] + async fn fapp_payload_routed_to_fapp_router() { + let identity = MeshIdentity::generate(); + let node_pk = identity.public_key(); + + let node = MeshNodeBuilder::new() + .identity(identity) + .fapp_relay() + .build() + .await + .expect("build fapp node"); + + // Build a FAPP query payload (tag 0x02 + CBOR body). + let query = crate::fapp::SlotQuery { + query_id: [0xAA; 16], + fachrichtung: None, + modalitaet: None, + kostentraeger: None, + plz_prefix: None, + earliest: None, + latest: None, + slot_type: None, + max_results: 5, + }; + let mut fapp_payload = vec![FAPP_WIRE_QUERY]; + ciborium::into_writer(&query, &mut fapp_payload).expect("CBOR encode"); + + // Wrap in a MeshEnvelope addressed to this node. + let sender = MeshIdentity::generate(); + let envelope = MeshEnvelope::new(&sender, &node_pk, fapp_payload, 3600, 5); + + let sender_addr = MeshAddress::from_public_key(&sender.public_key()); + let action = node.process_incoming(&sender_addr, envelope).expect("process"); + + match action { + IncomingAction::Fapp(FappAction::QueryResponse(resp)) => { + // Relay answers from its (empty) store — expect zero matches. + assert!(resp.matches.is_empty()); + } + other => panic!("expected Fapp(QueryResponse), got {:?}", std::mem::discriminant(&other)), + } + + node.shutdown().await; + } + + #[tokio::test] + async fn non_fapp_payload_delivered_normally() { + let identity = MeshIdentity::generate(); + let node_pk = identity.public_key(); + + let node = MeshNodeBuilder::new() + .identity(identity) + .fapp_relay() + .build() + .await + .expect("build fapp node"); + + // A regular (non-FAPP) payload — first byte 0xFF is not a FAPP tag. + let regular_payload = vec![0xFF, 0x01, 0x02, 0x03]; + let sender = MeshIdentity::generate(); + let envelope = MeshEnvelope::new(&sender, &node_pk, regular_payload.clone(), 3600, 5); + + let sender_addr = MeshAddress::from_public_key(&sender.public_key()); + let action = node.process_incoming(&sender_addr, envelope).expect("process"); + + match action { + IncomingAction::Deliver(env) => { + assert_eq!(env.payload, regular_payload); + } + other => panic!("expected Deliver, got {:?}", std::mem::discriminant(&other)), + } + + node.shutdown().await; + } + + #[tokio::test] + async fn fapp_payload_without_fapp_router_delivered_normally() { + let identity = MeshIdentity::generate(); + let node_pk = identity.public_key(); + + // No FAPP capabilities — fapp_router is None. + let node = MeshNodeBuilder::new() + .identity(identity) + .build() + .await + .expect("build node"); + + assert!(node.fapp_router().is_none()); + + // Even though the payload has a FAPP tag, without a FappRouter it should + // be delivered as a normal message. + let fapp_payload = vec![FAPP_WIRE_ANNOUNCE, 0x01, 0x02]; + let sender = MeshIdentity::generate(); + let envelope = MeshEnvelope::new(&sender, &node_pk, fapp_payload.clone(), 3600, 5); + + let sender_addr = MeshAddress::from_public_key(&sender.public_key()); + let action = node.process_incoming(&sender_addr, envelope).expect("process"); + + match action { + IncomingAction::Deliver(env) => { + assert_eq!(env.payload, fapp_payload); + } + other => panic!("expected Deliver, got {:?}", std::mem::discriminant(&other)), + } + + node.shutdown().await; + } } diff --git a/crates/quicprochat-p2p/src/mesh_router.rs b/crates/quicprochat-p2p/src/mesh_router.rs index eec9378..e7aeb63 100644 --- a/crates/quicprochat-p2p/src/mesh_router.rs +++ b/crates/quicprochat-p2p/src/mesh_router.rs @@ -21,6 +21,7 @@ use anyhow::{bail, Result}; use crate::announce::compute_address; use crate::envelope::MeshEnvelope; +use crate::fapp_router::FappAction; use crate::identity::MeshIdentity; use crate::routing_table::RoutingTable; use crate::store::MeshStore; @@ -54,6 +55,8 @@ pub enum IncomingAction { Store(MeshEnvelope), /// Message was dropped (expired, max hops, invalid). Dropped(String), + /// FAPP protocol message — handled by [`FappRouter`](crate::fapp_router::FappRouter). + Fapp(FappAction), } /// Per-destination delivery statistics.