From 2b6fe36208bc71d453a959998f2212cca2049a62 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 9 Sep 2026 12:44:42 -0600 Subject: [PATCH 1/5] feat: add RequestOutcome::ReplyFile for NomadNet file responses Serve raw file bytes as a response Resource with optional msgpack filename metadata, matching Python NomadNet serve_file behavior. --- crates/rns-runtime/src/lib.rs | 4 +- crates/rns-runtime/src/link_manager.rs | 197 ++++++++++++++++++++++++- crates/rns-runtime/src/rncp.rs | 8 +- 3 files changed, 196 insertions(+), 13 deletions(-) diff --git a/crates/rns-runtime/src/lib.rs b/crates/rns-runtime/src/lib.rs index a67c07a3..47ffadb5 100644 --- a/crates/rns-runtime/src/lib.rs +++ b/crates/rns-runtime/src/lib.rs @@ -39,7 +39,9 @@ pub mod prelude { RegisteredDestination, ResourceAcceptPolicy, }; pub use crate::lifecycle::ShutdownSignal; - pub use crate::link_manager::{DestinationAnnounceOptions, DestinationRequest, RequestOutcome}; + pub use crate::link_manager::{ + DestinationAnnounceOptions, DestinationRequest, RequestOutcome, pack_file_name_metadata, + }; pub use crate::link_session::{ LinkSession, LinkSessionChannelError, LinkSessionChannelHandle, LinkSessionCloseReason, LinkSessionError, LinkSessionEvent, LinkSessionHandle, LinkSessionResourceError, diff --git a/crates/rns-runtime/src/link_manager.rs b/crates/rns-runtime/src/link_manager.rs index 62ec51fa..a98a134b 100644 --- a/crates/rns-runtime/src/link_manager.rs +++ b/crates/rns-runtime/src/link_manager.rs @@ -402,6 +402,8 @@ impl std::fmt::Debug for LinkManagerAccountingEvent { /// Result of an extended request handler. `Reply` is the ordinary response; /// `ReplyWithResource` sends an inline ack followed by a resource transfer /// (rncp --fetch). Python: `RNS.Resource(..., target_link=link)`. +/// `ReplyFile` sends a **response** Resource with raw file bytes and optional +/// msgpack metadata (NomadNet `/file/...`: `{"name": }`). #[derive(Debug, Clone)] pub enum RequestOutcome { Reply(Vec), @@ -412,10 +414,32 @@ pub enum RequestOutcome { metadata: Option>, auto_compress: bool, }, + /// Response Resource with raw payload (not packed `[request_id, body]`). + /// + /// Python NomadNet `serve_file` returns `[file_handle, {"name": ...}]`, + /// which becomes `RNS.Resource(..., metadata=..., is_response=True)`. + ReplyFile { + data: Vec, + /// Optional msgpack-encoded metadata (e.g. from [`pack_file_name_metadata`]). + metadata: Option>, + auto_compress: bool, + }, /// Silently drop; caller sees a timeout. Useful for ACL denies. Drop, } +/// Pack NomadNet / rncp-compatible Resource filename metadata: +/// msgpack map `{"name": }`. +pub fn pack_file_name_metadata(file_name: &str) -> Vec { + let entries = vec![( + rmpv::Value::String(rmpv::Utf8String::from("name")), + rmpv::Value::Binary(file_name.as_bytes().to_vec()), + )]; + let mut buf = Vec::new(); + let _ = rmpv::encode::write_value(&mut buf, &rmpv::Value::Map(entries)); + buf +} + /// Python-compatible context supplied to a per-path Destination request handler. #[derive(Clone)] pub struct DestinationRequest { @@ -4685,15 +4709,20 @@ impl LinkManager { RequestOutcome::Drop }; - let (response, fetch_spec) = match outcome { - RequestOutcome::Reply(response) => (Some(response), None), + let (response, fetch_spec, file_spec) = match outcome { + RequestOutcome::Reply(response) => (Some(response), None, None), RequestOutcome::ReplyWithResource { ack, data, metadata, auto_compress, - } => (Some(ack), Some((data, metadata, auto_compress))), - RequestOutcome::Drop => (None, None), + } => (Some(ack), Some((data, metadata, auto_compress)), None), + RequestOutcome::ReplyFile { + data, + metadata, + auto_compress, + } => (None, None, Some((data, metadata, auto_compress))), + RequestOutcome::Drop => (None, None, None), }; if let Some(response) = response { @@ -4768,7 +4797,7 @@ impl LinkManager { ); } } - } else { + } else if file_spec.is_none() && fetch_spec.is_none() { tracing::debug!( link_id = hex::encode(link_id), request_id = hex::encode(request_id), @@ -4777,6 +4806,35 @@ impl LinkManager { ); } + if let Some((data, metadata, auto_compress)) = file_spec { + if self + .start_resource_transfer_inner( + &link_id, + ResourceTransferStart { + data, + metadata, + auto_compress, + request_id: Some(request_id.to_vec()), + is_response: true, + allow_handshake: true, + }, + ) + .is_none() + { + tracing::warn!( + link_id = hex::encode(link_id), + request_id = hex::encode(request_id), + "link request file response Resource could not be started" + ); + } else { + tracing::debug!( + link_id = hex::encode(link_id), + request_id = hex::encode(request_id), + "link request handled — file response Resource started" + ); + } + } + if let Some((data, metadata, auto_compress)) = fetch_spec { if self .start_resource_transfer_inner( @@ -7162,6 +7220,135 @@ mod tests { assert_eq!(response_data, b"ready"); } + #[test] + fn pack_file_name_metadata_uses_binary_name() { + let packed = pack_file_name_metadata("photos/pic.png"); + let value = rmpv::decode::read_value(&mut &packed[..]).unwrap(); + let map = value.as_map().expect("metadata map"); + assert_eq!(map.len(), 1); + assert_eq!(map[0].0.as_str(), Some("name")); + assert_eq!(map[0].1.as_slice(), Some(b"photos/pic.png".as_slice())); + } + + #[test] + fn reply_file_starts_response_resource_with_filename_metadata() { + let dest_hash = [0x42; 16]; + let identity_key = Ed25519PrivateKey::generate(); + let identity_pub = identity_key.public_key(); + let (mut initiator, request_data) = Link::new_initiator(dest_hash, 1); + let (responder, proof_data) = + Link::new_responder(&request_data, &identity_key, dest_hash, 1).unwrap(); + let _rtt_data = initiator + .validate_proof(&proof_data, &identity_pub, &identity_pub.to_bytes()) + .unwrap(); + let link_id = responder.link_id; + assert_eq!(responder.state, LinkState::Handshake); + + let (transport_tx, mut transport_rx) = mpsc::channel(16); + let (_event_tx, event_rx) = mpsc::channel(16); + let mut manager = LinkManager::new(transport_tx, event_rx, dest_hash, None); + manager.active_links.insert( + link_id, + ActiveLink { + link: responder, + _interface_id: 1, + channel: None, + inbound_resources: HashMap::new(), + outbound_resources: HashMap::new(), + outbound_split_queues: HashMap::new(), + inbound_split_resources: HashMap::new(), + segment_routing: HashMap::new(), + }, + ); + + let file_bytes = b"PNG-BYTES".to_vec(); + let metadata = pack_file_name_metadata("photos/pic.png"); + assert!(manager.register_request_handler( + "/file/photos/pic.png", + AllowPolicy::AllowAll, + None, + true, + { + let file_bytes = file_bytes.clone(); + let metadata = metadata.clone(); + move |_| RequestOutcome::ReplyFile { + data: file_bytes.clone(), + metadata: Some(metadata.clone()), + auto_compress: true, + } + }, + )); + + let (encrypted_request, _) = initiator + .request( + "/file/photos/pic.png", + None, + std::time::Duration::from_secs(5), + ) + .unwrap(); + let request_header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context: rns_wire::context::PacketContext::Request, + }; + let mut raw = request_header.pack(); + raw.extend_from_slice(&encrypted_request); + + manager.handle_inbound_packet(&raw, 1); + + let active = manager.active_links.get(&link_id).unwrap(); + assert_eq!( + active.outbound_resources.len(), + 1, + "ReplyFile must start one outbound response Resource" + ); + let transfer = active.outbound_resources.values().next().unwrap(); + assert!(transfer.resource.flags.is_response); + assert!(transfer.resource.flags.has_metadata); + let stored = transfer.resource.metadata.as_deref().expect("metadata"); + // OutboundResource frames metadata as `length(3 BE) || msgpack`. + assert!( + stored.windows(metadata.len()).any(|w| w == metadata.as_slice()), + "stored metadata should contain packed name map" + ); + assert_eq!( + transfer.resource.request_id.as_deref(), + Some( + rns_wire::hash::truncated_packet_hash(&raw, request_header.flags.header_type) + .as_slice() + ) + ); + + let TransportMessage::Outbound(adv_msg) = + next_transport_message(&mut transport_rx).expect("resource advertisement") + else { + panic!("expected outbound advertisement"); + }; + let (adv_header, _) = rns_wire::header::PacketHeader::unpack(&adv_msg.raw).unwrap(); + assert_eq!( + adv_header.context, + rns_wire::context::PacketContext::ResourceAdv + ); + // Drain any follow-up Resource parts; none should be an inline RESPONSE. + while let Ok(TransportMessage::Outbound(msg)) = next_transport_message(&mut transport_rx) { + let (header, _) = rns_wire::header::PacketHeader::unpack(&msg.raw).unwrap(); + assert_ne!( + header.context, + rns_wire::context::PacketContext::Response, + "ReplyFile must not emit a packed inline response" + ); + } + let _ = file_bytes; + } + #[test] fn test_destination_link_acceptance_gating() { let (tx, _transport_rx) = mpsc::channel(64); diff --git a/crates/rns-runtime/src/rncp.rs b/crates/rns-runtime/src/rncp.rs index be76a7b7..2d978607 100644 --- a/crates/rns-runtime/src/rncp.rs +++ b/crates/rns-runtime/src/rncp.rs @@ -683,13 +683,7 @@ pub async fn rncp_send_file(request: RncpSendRequest<'_>) -> Result Vec { - let entries = vec![( - rmpv::Value::String(rmpv::Utf8String::from("name")), - rmpv::Value::Binary(file_name.as_bytes().to_vec()), - )]; - let mut buf = Vec::new(); - let _ = rmpv::encode::write_value(&mut buf, &rmpv::Value::Map(entries)); - buf + crate::link_manager::pack_file_name_metadata(file_name) } struct OutboundDrive<'a> { From 36456230cc29be5722c6f57c95f52c3b655e97f6 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 9 Sep 2026 12:49:37 -0600 Subject: [PATCH 2/5] feat: return Resource metadata from LinkClient::query File response Resources (NomadNet serve_file) carry raw bytes plus filename metadata; expose both so clients can save under the right name. --- crates/rns-runtime/src/link_client.rs | 49 ++++++++++++++++++----- crates/rns-runtime/src/reticulum.rs | 4 +- crates/rns-tools/src/commands/rnpath.rs | 4 +- crates/rns-tools/src/commands/rnstatus.rs | 2 +- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/crates/rns-runtime/src/link_client.rs b/crates/rns-runtime/src/link_client.rs index 4f48bc87..fce828fe 100644 --- a/crates/rns-runtime/src/link_client.rs +++ b/crates/rns-runtime/src/link_client.rs @@ -45,6 +45,17 @@ pub enum LinkClientError { UnexpectedResponse(String), } +/// Successful Link request response. +/// +/// Ordinary replies carry packed response bytes in [`Self::data`] with +/// [`Self::metadata`] unset. NomadNet `/file/...` replies are response Resources +/// whose payload is raw file bytes plus optional msgpack filename metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkQueryResponse { + pub data: Vec, + pub metadata: Option>, +} + #[derive(Clone)] pub struct LinkClient { transport_tx: mpsc::Sender, @@ -66,7 +77,7 @@ impl LinkClient { } /// Open a Link to `app_name` on `remote_transport_hash`, send one - /// request, return the response. + /// request, return the response (and optional Resource metadata). pub async fn query( &self, remote_transport_hash: [u8; 16], @@ -75,7 +86,7 @@ impl LinkClient { payload: Vec, hops: u8, overall_timeout: Duration, - ) -> Result, LinkClientError> { + ) -> Result { let started = Instant::now(); let deadline = started + overall_timeout; @@ -280,7 +291,7 @@ async fn wait_for_response( link_id: [u8; 16], request_id: [u8; 16], deadline: Duration, -) -> Result, LinkClientError> { +) -> Result { let fut = async { let mut inbound_resources: HashMap<[u8; 32], InboundTransfer> = HashMap::new(); @@ -318,7 +329,10 @@ async fn wait_for_response( match link.handle_response(body) { Ok((id, response_data)) => { if id == request_id { - return Ok(response_data); + return Ok(LinkQueryResponse { + data: response_data, + metadata: None, + }); } } Err(e) => { @@ -432,7 +446,7 @@ async fn wait_for_response( } if let Some(rh) = completed_rh { - let (assembled, proof) = { + let (assembled, proof, metadata) = { let transfer = inbound_resources.get_mut(&rh).ok_or_else(|| { LinkClientError::UnexpectedResponse( @@ -451,19 +465,32 @@ async fn wait_for_response( }, ) }; - transfer.complete(Some(&decrypt_fn)).map_err(|e| { - LinkClientError::UnexpectedResponse(format!( - "resource assemble: {e:?}" - )) - })? + let (assembled, proof) = + transfer.complete(Some(&decrypt_fn)).map_err(|e| { + LinkClientError::UnexpectedResponse(format!( + "resource assemble: {e:?}" + )) + })?; + let metadata = transfer.resource.metadata.clone(); + (assembled, proof, metadata) }; send_link_proof(transport_tx, link_id, &proof).await?; inbound_resources.remove(&rh); + if metadata.is_some() { + // NomadNet file response: raw payload + Resource metadata. + return Ok(LinkQueryResponse { + data: assembled, + metadata, + }); + } match link.handle_response_plaintext(&assembled) { Ok((id, response_data)) => { if id == request_id { - return Ok(response_data); + return Ok(LinkQueryResponse { + data: response_data, + metadata: None, + }); } } Err(e) => { diff --git a/crates/rns-runtime/src/reticulum.rs b/crates/rns-runtime/src/reticulum.rs index 121e565b..171c9da8 100644 --- a/crates/rns-runtime/src/reticulum.rs +++ b/crates/rns-runtime/src/reticulum.rs @@ -6189,7 +6189,9 @@ async fn start_blackhole_subscriber(handle: ReticulumHandle) { { Ok(payload) => { match handle - .query_transport(TransportQuery::ApplyBlackholeManifest { payload }) + .query_transport(TransportQuery::ApplyBlackholeManifest { + payload: payload.data, + }) .await { Some(TransportQueryResponse::IntResult(applied)) => { diff --git a/crates/rns-tools/src/commands/rnpath.rs b/crates/rns-tools/src/commands/rnpath.rs index 22eea9a2..663fea37 100644 --- a/crates/rns-tools/src/commands/rnpath.rs +++ b/crates/rns-tools/src/commands/rnpath.rs @@ -951,7 +951,7 @@ async fn run_remote_blackhole_list(args: Args) -> ExitCode { ) .await { - Ok(b) => b, + Ok(b) => b.data, Err(e) => { eprintln!( "rnpath-rs: remote blackhole query failed: {}", @@ -1183,7 +1183,7 @@ async fn run_remote(args: Args) -> ExitCode { ) .await { - Ok(b) => b, + Ok(b) => b.data, Err(e) => { eprintln!("rnpath-rs: remote query failed: {}", remote_err(&e)); shutdown.trigger(); diff --git a/crates/rns-tools/src/commands/rnstatus.rs b/crates/rns-tools/src/commands/rnstatus.rs index a79fac58..2a412599 100644 --- a/crates/rns-tools/src/commands/rnstatus.rs +++ b/crates/rns-tools/src/commands/rnstatus.rs @@ -1006,7 +1006,7 @@ impl RemoteSession { ) .await { - Ok(b) => b, + Ok(b) => b.data, Err(e) => { eprintln!("rnstatus-rs: remote query failed: {}", remote_err(&e)); return match e { From 90774570eae7498554b4809d9203765368c5bbed Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 9 Sep 2026 14:48:56 -0600 Subject: [PATCH 3/5] style: rustfmt ReplyFile metadata test assertion wrap --- crates/rns-runtime/src/link_manager.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/rns-runtime/src/link_manager.rs b/crates/rns-runtime/src/link_manager.rs index a98a134b..34c1ab1e 100644 --- a/crates/rns-runtime/src/link_manager.rs +++ b/crates/rns-runtime/src/link_manager.rs @@ -7316,7 +7316,9 @@ mod tests { let stored = transfer.resource.metadata.as_deref().expect("metadata"); // OutboundResource frames metadata as `length(3 BE) || msgpack`. assert!( - stored.windows(metadata.len()).any(|w| w == metadata.as_slice()), + stored + .windows(metadata.len()) + .any(|w| w == metadata.as_slice()), "stored metadata should contain packed name map" ); assert_eq!( From 66b7792d38f9e5fdc31670526e760046e59ba30d Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 9 Sep 2026 17:00:47 -0600 Subject: [PATCH 4/5] feat: pass remote identity to set_request_handler_ex NomadNet .allowed ACLs need the peer identity on catch-all request handlers; DestinationRequest already exposed it for per-path handlers. --- crates/rns-runtime/src/link_manager.rs | 12 +++++++++--- crates/rns-runtime/src/rncp.rs | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/rns-runtime/src/link_manager.rs b/crates/rns-runtime/src/link_manager.rs index 34c1ab1e..36b8b920 100644 --- a/crates/rns-runtime/src/link_manager.rs +++ b/crates/rns-runtime/src/link_manager.rs @@ -455,7 +455,10 @@ pub type DestinationRequestHandler = Box RequestOutcome + Send + 'static>; type RequestHandler = Box) -> Option> + Send>; -type RequestHandlerEx = Box) -> RequestOutcome + Send>; +/// Extended catch-all handler: link id, path hash, request body, remote identity +/// (when the peer identified on the Link). +type RequestHandlerEx = + Box, Option) -> RequestOutcome + Send>; type LinkIdentityGate = Box bool + Send>; type ResourceAcceptHandler = Box bool + Send>; @@ -4699,7 +4702,7 @@ impl LinkManager { RequestOutcome::Drop } } else if let Some(ref handler) = self.request_handler_ex { - handler(link_id, path_hash, data.clone()) + handler(link_id, path_hash, data.clone(), remote_identity) } else if let Some(ref handler) = self.request_handler { match handler(link_id, path_hash, data) { Some(response) => RequestOutcome::Reply(response), @@ -4867,9 +4870,12 @@ impl LinkManager { /// Handler that may schedule a follow-up resource transfer (rncp --fetch). /// Takes precedence over [`Self::set_request_handler`]. + /// + /// The fourth argument is the authenticated remote identity when the peer + /// identified on the Link (needed for NomadNet `.allowed` ACLs). pub fn set_request_handler_ex(&mut self, handler: F) where - F: Fn([u8; 16], [u8; 16], Vec) -> RequestOutcome + Send + 'static, + F: Fn([u8; 16], [u8; 16], Vec, Option) -> RequestOutcome + Send + 'static, { self.request_handler_ex = Some(Box::new(handler)); } diff --git a/crates/rns-runtime/src/rncp.rs b/crates/rns-runtime/src/rncp.rs index 2d978607..041b1467 100644 --- a/crates/rns-runtime/src/rncp.rs +++ b/crates/rns-runtime/src/rncp.rs @@ -279,7 +279,7 @@ pub async fn spawn_rncp_listener( let link_identities = link_mgr.link_identities_handle(); let fetch_events = events_tx.clone(); let fetch_path_hash = truncated_hash(FETCH_PATH_NAME.as_bytes()); - link_mgr.set_request_handler_ex(move |link_id, path_hash, data| { + link_mgr.set_request_handler_ex(move |link_id, path_hash, data, _remote_identity| { if path_hash != fetch_path_hash { return RequestOutcome::Drop; } From e16bd152256a5caffb704446bbe15530c1b20f48 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 9 Sep 2026 17:01:05 -0600 Subject: [PATCH 5/5] chore(api): update set_request_handler_ex snapshot for identity arg --- api/snapshots/rns-runtime.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/snapshots/rns-runtime.txt b/api/snapshots/rns-runtime.txt index b7fca020..f1ededf1 100644 --- a/api/snapshots/rns-runtime.txt +++ b/api/snapshots/rns-runtime.txt @@ -587,7 +587,7 @@ pub fn rns_runtime::link_manager::LinkManager::set_link_packet_channel(&mut self pub fn rns_runtime::link_manager::LinkManager::set_link_packet_proof_channel(&mut self, tokio::sync::mpsc::bounded::Sender) pub fn rns_runtime::link_manager::LinkManager::set_outbound_resource_proof_channel(&mut self, tokio::sync::mpsc::bounded::Sender) pub fn rns_runtime::link_manager::LinkManager::set_request_handler(&mut self, F) where F: core::ops::function::Fn([u8; 16], [u8; 16], alloc::vec::Vec) -> core::option::Option> + core::marker::Send + 'static -pub fn rns_runtime::link_manager::LinkManager::set_request_handler_ex(&mut self, F) where F: core::ops::function::Fn([u8; 16], [u8; 16], alloc::vec::Vec) -> rns_runtime::link_manager::RequestOutcome + core::marker::Send + 'static +pub fn rns_runtime::link_manager::LinkManager::set_request_handler_ex(&mut self, F) where F: core::ops::function::Fn([u8; 16], [u8; 16], alloc::vec::Vec, core::option::Option) -> rns_runtime::link_manager::RequestOutcome + core::marker::Send + 'static pub fn rns_runtime::link_manager::LinkManager::set_resource_accept_handler(&mut self, F) where F: core::ops::function::Fn([u8; 16], &rns_protocol::resource_adv::ResourceAdvertisement) -> bool + core::marker::Send + 'static pub fn rns_runtime::link_manager::LinkManager::set_resource_completed_channel(&mut self, tokio::sync::mpsc::bounded::Sender<(alloc::vec::Vec, [u8; 16])>) pub fn rns_runtime::link_manager::LinkManager::set_resource_completion_channel(&mut self, tokio::sync::mpsc::bounded::Sender)