From c3d8b44942e7726dbbe6bb53e0976d4c72134119 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 9 Sep 2026 12:47:26 -0600 Subject: [PATCH] feat: multi-file LXMF attachment pack and list APIs Add set_file_attachments_field and file_attachments for the full [[name, bytes], ...] NomadNet/Python shape, and document media field APIs. --- README.md | 10 ++- crates/lxmf-core/src/message.rs | 112 ++++++++++++++++++++++++-------- 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c708d18..f549b5c 100644 --- a/README.md +++ b/README.md @@ -312,10 +312,18 @@ signature 64 bytes payload MessagePack([timestamp, title, content, fields, optional_stamp]) ``` -`title` and `content` are bytes on the wire. `fields` is a `map` for +`title` and `content` are bytes on the wire. `fields` is a `map` for application-defined data such as tickets, attachments, location data, or application envelopes. +Native media fields (use typed setters — do not `set_field` raw bins for these): + +| Field | Wire shape | API | +| --- | --- | --- | +| `FIELD_IMAGE` (`0x06`) | `[format, bytes]` | `set_image_field` / `image_attachment` | +| `FIELD_FILE_ATTACHMENTS` (`0x05`) | `[[name, bytes], …]` | `set_file_attachments_field` / `file_attachments` (single-file helper: `set_file_attachment_field`) | +| `FIELD_AUDIO` (`0x07`) | `[mode, bytes]` | `set_audio_field` / `audio_field` | + Library callers requesting a reply ticket set `include_ticket`, call `LxmRouter::prepare_outbound`, and then sign the message. The router rejects a requested ticket that would require changing an already-signed message. diff --git a/crates/lxmf-core/src/message.rs b/crates/lxmf-core/src/message.rs index efbe123..b914362 100644 --- a/crates/lxmf-core/src/message.rs +++ b/crates/lxmf-core/src/message.rs @@ -316,11 +316,19 @@ impl LxMessage { file_name: &str, file_bytes: &[u8], ) -> Result<(), MessageError> { - let encoded = rmp_serde::to_vec(&FileAttachmentsFieldRef { - file_name, - bytes: file_bytes, - }) - .map_err(|error| MessageError::PackFailed(error.to_string()))?; + self.set_file_attachments_field(&[(file_name, file_bytes)]) + } + + /// Encode and install native LXMF file attachments (`[[name, bytes], ...]`). + /// + /// Matches Python NomadNet / LXMF multi-file attachment lists. Replaces any + /// previous `FIELD_FILE_ATTACHMENTS` value. + pub fn set_file_attachments_field( + &mut self, + attachments: &[(&str, &[u8])], + ) -> Result<(), MessageError> { + let encoded = rmp_serde::to_vec(&FileAttachmentsListRef { attachments }) + .map_err(|error| MessageError::PackFailed(error.to_string()))?; self.fields.insert(FIELD_FILE_ATTACHMENTS, encoded); self.msgpack_field_ids.insert(FIELD_FILE_ATTACHMENTS); Ok(()) @@ -333,22 +341,30 @@ impl LxMessage { /// Decode the first native LXMF file attachment while borrowing its bytes /// from this message. Only the small filename is allocated. pub fn first_file_attachment(&self) -> Result, MessageError> { + Ok(self.file_attachments()?.into_iter().next()) + } + + /// Decode all native LXMF file attachments, borrowing each payload from + /// this message. Filenames are allocated; bytes are not. + pub fn file_attachments(&self) -> Result, MessageError> { let Some(field) = self.fields.get(&FIELD_FILE_ATTACHMENTS) else { - return Ok(None); + return Ok(Vec::new()); }; let mut input = field.as_slice(); - if read_msgpack_array_len(&mut input)? == 0 { - return Ok(None); - } - if read_msgpack_array_len(&mut input)? < 2 { - return Err(MessageError::UnpackFailed( - "file attachment entry requires name and data".to_string(), - )); + let count = read_msgpack_array_len(&mut input)? as usize; + let mut out = Vec::with_capacity(count); + for _ in 0..count { + if read_msgpack_array_len(&mut input)? < 2 { + return Err(MessageError::UnpackFailed( + "file attachment entry requires name and data".to_string(), + )); + } + let file_name = + String::from_utf8_lossy(read_msgpack_string_or_binary(&mut input)?).into_owned(); + let data = read_msgpack_binary(&mut input)?; + out.push((file_name, data)); } - let file_name = - String::from_utf8_lossy(read_msgpack_string_or_binary(&mut input)?).into_owned(); - let data = read_msgpack_binary(&mut input)?; - Ok(Some((file_name, data))) + Ok(out) } /// Decode the native LXMF image field while borrowing its image bytes @@ -1742,12 +1758,11 @@ impl serde::Serialize for AudioFieldRef<'_> { } } -struct FileAttachmentsFieldRef<'a> { - file_name: &'a str, - bytes: &'a [u8], +struct FileAttachmentsListRef<'a> { + attachments: &'a [(&'a str, &'a [u8])], } -impl serde::Serialize for FileAttachmentsFieldRef<'_> { +impl serde::Serialize for FileAttachmentsListRef<'_> { fn serialize(&self, serializer: S) -> Result { use serde::ser::{SerializeSeq, SerializeTuple}; @@ -1765,11 +1780,13 @@ impl serde::Serialize for FileAttachmentsFieldRef<'_> { } } - let mut attachments = serializer.serialize_seq(Some(1))?; - attachments.serialize_element(&AttachmentRef { - file_name: self.file_name, - bytes: self.bytes, - })?; + let mut attachments = serializer.serialize_seq(Some(self.attachments.len()))?; + for (file_name, bytes) in self.attachments { + attachments.serialize_element(&AttachmentRef { + file_name, + bytes, + })?; + } attachments.end() } } @@ -2451,6 +2468,49 @@ mod tests { assert!(msg.msgpack_field_ids.contains(&FIELD_FILE_ATTACHMENTS)); } + #[test] + fn typed_multi_file_attachments_round_trip() { + let a = b"one"; + let b = b"two"; + let expected_value = rmpv::Value::Array(vec![ + rmpv::Value::Array(vec![ + rmpv::Value::String("a.txt".into()), + rmpv::Value::Binary(a.to_vec()), + ]), + rmpv::Value::Array(vec![ + rmpv::Value::String("b.bin".into()), + rmpv::Value::Binary(b.to_vec()), + ]), + ]); + let mut expected = Vec::new(); + rmpv::encode::write_value(&mut expected, &expected_value).unwrap(); + + let mut msg = LxMessage::new([0; 16], [0; 16], "", "", DeliveryMethod::Direct); + msg.set_file_attachments_field(&[("a.txt", a.as_slice()), ("b.bin", b.as_slice())]) + .unwrap(); + assert_eq!(msg.get_field(FIELD_FILE_ATTACHMENTS), Some(&expected)); + + let listed = msg.file_attachments().unwrap(); + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].0, "a.txt"); + assert_eq!(listed[0].1, a); + assert_eq!(listed[1].0, "b.bin"); + assert_eq!(listed[1].1, b); + assert_eq!( + msg.first_file_attachment().unwrap().unwrap().0, + "a.txt" + ); + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + msg.sign(&key).unwrap(); + let packed = msg.pack().unwrap(); + let inbound = LxMessage::unpack(&packed).unwrap(); + let inbound_files = inbound.file_attachments().unwrap(); + assert_eq!(inbound_files.len(), 2); + assert_eq!(inbound_files[1].0, "b.bin"); + assert_eq!(inbound_files[1].1, b); + } + #[test] fn native_attachment_decoders_borrow_large_payload_bytes() { let file_bytes = vec![0x5a; 2 * 1024 * 1024];