Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8, bytes>` for
`title` and `content` are bytes on the wire. `fields` is a `map<u8, value>` 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.
Expand Down
112 changes: 86 additions & 26 deletions crates/lxmf-core/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand All @@ -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<Option<(String, &[u8])>, 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<Vec<(String, &[u8])>, 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
Expand Down Expand Up @@ -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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::{SerializeSeq, SerializeTuple};

Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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];
Expand Down