feat: wire FAPP message handling into mesh router

When a MeshEnvelope is delivered locally and its payload starts with a
known FAPP wire tag (0x01-0x05), MeshNode.process_incoming now delegates
to FappRouter instead of returning a raw Deliver action. Nodes without
FAPP capabilities still receive FAPP-tagged payloads as normal Deliver
actions, preserving backward compatibility.

Adds IncomingAction::Fapp variant, is_fapp_payload() helper, and three
integration tests covering the routing, passthrough, and no-router cases.
This commit is contained in:
2026-04-03 07:44:19 +02:00
parent 8eba12170e
commit fb6b80c81c
3 changed files with 161 additions and 1 deletions

View File

@@ -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))));

View File

@@ -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;
}
}

View File

@@ -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.