archy/core/archipelago/src/mesh/protocol.rs
2026-05-17 18:07:40 -04:00

817 lines
28 KiB
Rust

// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Meshcore binary frame protocol: constants, encoding, decoding, command builders.
//!
//! Frame format (USB serial):
//! - Outbound (host -> device): `<` (0x3C) + 2-byte LE length + frame data
//! - Inbound (device -> host): `>` (0x3E) + 2-byte LE length + frame data
//! - Baud: 115200, 8N1
//! - Max message payload: 160 bytes
use anyhow::Result;
// --- Frame markers ---
pub const OUTBOUND_MARKER: u8 = 0x3C; // '<' (host -> device)
pub const INBOUND_MARKER: u8 = 0x3E; // '>' (device -> host)
// --- Commands (host -> device) ---
pub const CMD_APP_START: u8 = 0x01;
pub const CMD_SEND_TXT_MSG: u8 = 0x02;
pub const CMD_SEND_CHANNEL_TXT_MSG: u8 = 0x03;
pub const CMD_GET_CONTACTS: u8 = 0x04;
pub const CMD_GET_DEVICE_TIME: u8 = 0x05;
pub const CMD_SET_DEVICE_TIME: u8 = 0x06;
pub const CMD_SEND_SELF_ADVERT: u8 = 0x07;
pub const CMD_SET_ADVERT_NAME: u8 = 0x08;
pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
/// CMD_RESET_PATH (0x0D): Tell the firmware to drop the stored route for
/// a contact and fall back to flood routing (out_path_len = 0xFF). Used to
/// unstick direct messages to contacts whose `path_len=0` means "no route
/// known" — without this, the firmware silently drops outbound TXT_MSG
/// frames to such contacts.
pub const CMD_RESET_PATH: u8 = 0x0D;
pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B;
pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C;
pub const CMD_SET_TUNING_PARAMS: u8 = 0x15;
pub const CMD_DEVICE_QUERY: u8 = 0x16;
pub const CMD_GET_CHANNEL: u8 = 0x1F;
pub const CMD_SET_CHANNEL: u8 = 0x20;
pub const CMD_GET_STATS: u8 = 0x38;
// --- Response codes (device -> host, synchronous) ---
pub const RESP_OK: u8 = 0x00;
pub const RESP_ERR: u8 = 0x01;
pub const RESP_CONTACT_START: u8 = 0x02;
pub const RESP_CONTACT: u8 = 0x03;
pub const RESP_CONTACT_END: u8 = 0x04;
pub const RESP_SELF_INFO: u8 = 0x05;
pub const RESP_SENT: u8 = 0x06;
pub const RESP_CONTACT_MSG: u8 = 0x07;
pub const RESP_CHANNEL_MSG: u8 = 0x08;
pub const RESP_CURRENT_TIME: u8 = 0x09;
pub const RESP_NO_MORE_MESSAGES: u8 = 0x0A;
pub const RESP_CONTACT_URI: u8 = 0x0B;
pub const RESP_BATTERY: u8 = 0x0C;
pub const RESP_DEVICE_INFO: u8 = 0x0D;
pub const RESP_CONTACT_MSG_V3: u8 = 0x10;
pub const RESP_CHANNEL_MSG_V3: u8 = 0x11;
pub const RESP_CHANNEL_INFO: u8 = 0x12;
pub const RESP_STATS: u8 = 0x18;
// --- Push notification codes (device -> host, async, >= 0x80) ---
pub const PUSH_CONTACT_ADVERT: u8 = 0x80;
pub const PUSH_PATH_UPDATE: u8 = 0x81;
pub const PUSH_ACK: u8 = 0x82;
pub const PUSH_MESSAGES_WAITING: u8 = 0x83;
pub const PUSH_RAW_DATA: u8 = 0x84;
pub const PUSH_LOG_DATA: u8 = 0x88;
pub const PUSH_NEW_CONTACT: u8 = 0x8A;
// --- Error codes ---
pub const ERR_UNSUPPORTED_CMD: u8 = 0x01;
pub const ERR_NOT_FOUND: u8 = 0x02;
pub const ERR_TABLE_FULL: u8 = 0x03;
pub const ERR_BAD_STATE: u8 = 0x04;
pub const ERR_FILE_IO: u8 = 0x05;
pub const ERR_ILLEGAL_ARG: u8 = 0x06;
/// Maximum payload size for a single LoRa message.
pub const MAX_MESSAGE_LEN: usize = 160;
/// Marker byte for "direct message wrapped as channel broadcast". Our
/// meshcore devices can hear each other's channel broadcasts (via
/// repeater flooding) but direct unicast frames don't reach between
/// archipelago nodes — so we emulate DMs by sending them on the shared
/// channel with a recipient pubkey-prefix header. Format:
/// `[DM_VIA_CHANNEL_MARKER][dest_pubkey_prefix(6B)][inner_payload…]`
/// The inner payload is whatever we would have sent directly — a typed
/// envelope, a chunked MC frame, or plain text.
pub const DM_VIA_CHANNEL_MARKER: u8 = 0xD1;
/// Minimum frame size: marker (1) + length (2) + command/response (1) = 4 bytes.
const MIN_FRAME_SIZE: usize = 4;
/// Protocol version we advertise during handshake.
const PROTOCOL_VERSION: u8 = 3;
// ─── Frame encoding ─────────────────────────────────────────────────────
/// Encode a command frame for sending to the device.
/// Returns: `>` + 2-byte LE length + data
pub fn encode_frame(data: &[u8]) -> Vec<u8> {
let len = data.len() as u16;
let mut frame = Vec::with_capacity(3 + data.len());
frame.push(OUTBOUND_MARKER);
frame.extend_from_slice(&len.to_le_bytes());
frame.extend_from_slice(data);
frame
}
/// Result of parsing one inbound frame from the device.
#[derive(Debug)]
pub struct InboundFrame {
/// Response or push notification code (first byte of payload).
pub code: u8,
/// Remaining payload after the code byte.
pub data: Vec<u8>,
/// Total bytes consumed from the buffer (for advancing read position).
pub bytes_consumed: usize,
}
/// Try to parse one inbound frame from a buffer.
/// Returns `None` if the buffer doesn't contain a complete frame yet.
pub fn decode_frame(buf: &[u8]) -> Option<InboundFrame> {
if buf.len() < MIN_FRAME_SIZE {
return None;
}
// Find the inbound marker
let start = buf.iter().position(|&b| b == INBOUND_MARKER)?;
let remaining = &buf[start..];
if remaining.len() < 3 {
return None;
}
let len = u16::from_le_bytes([remaining[1], remaining[2]]) as usize;
let total = 3 + len; // marker + 2 length bytes + payload
if remaining.len() < total {
return None; // incomplete frame
}
if len == 0 {
return None; // empty payload is invalid
}
let payload = &remaining[3..total];
let code = payload[0];
let data = payload[1..].to_vec();
Some(InboundFrame {
code,
data,
bytes_consumed: start + total,
})
}
// ─── Command builders ───────────────────────────────────────────────────
/// CMD_DEVICE_QUERY (0x16): Query device capabilities and negotiate protocol version.
pub fn build_device_query() -> Vec<u8> {
encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION])
}
/// CMD_APP_START (0x01): Initialize communication session.
/// Format matches official meshcore_py: [0x01][version][padded_name]
/// The official library sends: b"\x01\x03 mccli"
pub fn build_app_start(app_name: &str) -> Vec<u8> {
let mut data = vec![CMD_APP_START, PROTOCOL_VERSION];
// Pad name to 6 chars minimum (matching official library behavior)
let name_bytes = app_name.as_bytes();
let padded_len = name_bytes.len().max(6);
let len = padded_len.min(32);
// Pad with spaces if name is shorter than 6 chars
for i in 0..len {
if i < name_bytes.len() {
data.push(name_bytes[i]);
} else {
data.push(b' ');
}
}
encode_frame(&data)
}
/// CMD_SET_DEVICE_TIME (0x06): Sync device clock with Unix timestamp.
pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
let mut data = vec![CMD_SET_DEVICE_TIME];
data.extend_from_slice(&(unix_secs as u32).to_le_bytes());
encode_frame(&data)
}
/// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
pub fn build_set_advert_name(name: &str) -> Vec<u8> {
let mut data = vec![CMD_SET_ADVERT_NAME];
let name_bytes = name.as_bytes();
let len = name_bytes.len().min(32);
data.extend_from_slice(&name_bytes[..len]);
encode_frame(&data)
}
/// CMD_SEND_TXT_MSG (0x02): Send a text message to a specific contact.
/// Destination is the first 6 bytes of the contact's public key (hex decoded).
/// Format: 0x02 + 0x00 (txt_type) + attempt(1B) + timestamp(4B LE) + dest_prefix(6B) + text
pub fn build_send_text(dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<Vec<u8>> {
if msg.len() > MAX_MESSAGE_LEN {
anyhow::bail!(
"Message too large for LoRa: {} bytes (max {})",
msg.len(),
MAX_MESSAGE_LEN
);
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as u32;
let mut data = vec![CMD_SEND_TXT_MSG, 0x00, 0x00]; // cmd + txt_type=0 + attempt=0
data.extend_from_slice(&timestamp.to_le_bytes());
data.extend_from_slice(dest_pubkey_prefix);
data.extend_from_slice(msg);
Ok(encode_frame(&data))
}
/// CMD_SEND_CHANNEL_TXT_MSG (0x03): Broadcast a text message on a channel.
/// Frame layout per meshcore companion protocol:
/// `[0x03][txt_type=0][channel][timestamp_le32][text…]`
/// The txt_type and timestamp fields are mandatory — without them the
/// firmware rejects the command with ERR_UNSUPPORTED.
pub fn build_send_channel_text(channel: u8, msg: &[u8]) -> Result<Vec<u8>> {
if msg.len() > MAX_MESSAGE_LEN {
anyhow::bail!(
"Message too large for LoRa: {} bytes (max {})",
msg.len(),
MAX_MESSAGE_LEN
);
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as u32)
.unwrap_or(0);
let mut data = vec![CMD_SEND_CHANNEL_TXT_MSG, 0x00, channel];
data.extend_from_slice(&timestamp.to_le_bytes());
data.extend_from_slice(msg);
Ok(encode_frame(&data))
}
/// CMD_GET_CONTACTS (0x04): Request the contact list from the device.
pub fn build_get_contacts() -> Vec<u8> {
encode_frame(&[CMD_GET_CONTACTS])
}
/// CMD_RESET_PATH (0x0D): `[0x0D][pub_key:32]`. Clears the stored route
/// for a contact so subsequent sends route via flood instead of being
/// silently dropped.
pub fn build_reset_path(pubkey: &[u8; 32]) -> Vec<u8> {
let mut data = vec![CMD_RESET_PATH];
data.extend_from_slice(pubkey);
encode_frame(&data)
}
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
pub fn build_sync_next_message() -> Vec<u8> {
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
}
/// CMD_SEND_SELF_ADVERT (0x07): Broadcast our advertisement to the mesh.
pub fn build_send_self_advert() -> Vec<u8> {
encode_frame(&[CMD_SEND_SELF_ADVERT])
}
/// CMD_GET_STATS (0x38): Request device statistics.
pub fn build_get_stats() -> Vec<u8> {
encode_frame(&[CMD_GET_STATS])
}
// ─── Response parsers ───────────────────────────────────────────────────
/// Parse RESP_DEVICE_INFO (0x0D) response.
/// Returns firmware version string and device capabilities.
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
// Device info format varies by firmware version.
// Minimum: firmware version string (null-terminated) + max_contacts (u16 LE)
if data.is_empty() {
anyhow::bail!("Empty device info response");
}
// Find null terminator for version string, or use all data as version
let version_end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
let version = String::from_utf8_lossy(&data[..version_end]).to_string();
let max_contacts = if data.len() > version_end + 2 {
u16::from_le_bytes([data[version_end + 1], data[version_end + 2]])
} else {
100 // default
};
Ok((version, max_contacts))
}
/// Parse RESP_SELF_INFO (0x05) response.
/// Returns (node_id, advert_name).
pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
if data.len() < 4 {
anyhow::bail!("Self info response too short: {} bytes", data.len());
}
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
// Name follows after fixed fields — find it by scanning for printable ASCII
let name_start = 4;
let name = if data.len() > name_start {
let name_end = data[name_start..]
.iter()
.position(|&b| b == 0)
.map(|p| name_start + p)
.unwrap_or(data.len());
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
} else {
String::new()
};
Ok((node_id, name))
}
/// Parsed contact from RESP_CONTACT (0x03).
#[derive(Clone)]
pub struct ParsedContact {
pub public_key_hex: String,
pub advert_name: String,
pub last_advert: u32,
pub contact_type: u8,
pub path_len: u8,
pub flags: u8,
}
/// Parse RESP_CONTACT (0x03) response.
/// Format: 32B pubkey + 1B type + 1B flags + 1B path_len + 64B path + 32B name + 4B last_advert + 4B lat + 4B lon + 4B lastmod
pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
if data.len() < 34 {
anyhow::bail!(
"Contact response too short: {} bytes (need >= 34)",
data.len()
);
}
let public_key_hex = hex::encode(&data[0..32]);
let contact_type = data[32];
let flags = if data.len() > 33 { data[33] } else { 0 };
let path_len = if data.len() > 34 { data[34] } else { 0 };
// path at data[35..99] (64 bytes)
// name at data[99..131] (32 bytes)
let name_start = 99.min(data.len());
let name_end = (name_start + 32).min(data.len());
let advert_name = if data.len() > name_start {
String::from_utf8_lossy(&data[name_start..name_end])
.trim_end_matches('\0')
.to_string()
} else {
format!("{}...", &public_key_hex[..8])
};
// last_advert at data[131..135]
let last_advert = if data.len() >= 135 {
u32::from_le_bytes([data[131], data[132], data[133], data[134]])
} else {
0
};
Ok(ParsedContact {
public_key_hex,
advert_name,
last_advert,
contact_type,
path_len,
flags,
})
}
/// Parse RESP_CONTACT_MSG_V3 (0x10) - private message.
/// Format: SNR(1B) + reserved(2B) + pubkey_prefix(6B) + path_len(1B) + txt_type(1B) + timestamp(4B) + [sig(4B) if txt_type==2] + text
/// Returns (sender_pubkey_prefix_hex, text, snr).
pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> {
if data.len() < 15 {
anyhow::bail!("Contact message too short: {} bytes", data.len());
}
let snr = data[0] as i8;
// data[1..3] reserved
let pubkey_prefix = hex::encode(&data[3..9]);
// data[9] = path_len
let txt_type = data[10];
// data[11..15] = timestamp
let text_start = if txt_type == 2 { 19 } else { 15 }; // skip 4-byte signature if txt_type==2
let text = if data.len() > text_start {
String::from_utf8_lossy(&data[text_start..]).to_string()
} else {
String::new()
};
Ok((pubkey_prefix, text, snr))
}
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
/// Returns (channel_idx, text).
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
if data.len() < 7 {
anyhow::bail!("Channel message too short: {} bytes", data.len());
}
let channel_idx = data[0];
// data[1] = path_len, data[2] = txt_type
// data[3..7] = timestamp
let text = if data.len() > 7 {
String::from_utf8_lossy(&data[7..])
.trim_end_matches('\0')
.to_string()
} else {
String::new()
};
Ok((channel_idx, text))
}
/// Parse RESP_CONTACT_MSG (0x07) - v1 private message.
/// Format: pubkey_prefix(6B) + path_len(1B) + txt_type(1B) + timestamp(4B) + [sig(4B) if txt_type==2] + text
/// Returns (sender_pubkey_prefix_hex, text).
pub fn parse_contact_msg_v1(data: &[u8]) -> Result<(String, String)> {
if data.len() < 12 {
anyhow::bail!("Contact message v1 too short: {} bytes", data.len());
}
let pubkey_prefix = hex::encode(&data[0..6]);
// data[6] = path_len, data[7] = txt_type
let txt_type = data[7];
// data[8..12] = timestamp
let text_start = if txt_type == 2 { 16 } else { 12 };
let text = if data.len() > text_start {
String::from_utf8_lossy(&data[text_start..]).to_string()
} else {
String::new()
};
Ok((pubkey_prefix, text))
}
/// Parse RESP_CHANNEL_MSG (0x08) - v1 channel message.
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
pub fn parse_channel_msg_v1(data: &[u8]) -> Result<(u8, String)> {
if data.len() < 7 {
anyhow::bail!("Channel message v1 too short: {} bytes", data.len());
}
let channel_idx = data[0];
// data[1] = path_len, data[2] = txt_type
// data[3..7] = timestamp
let text = if data.len() > 7 {
String::from_utf8_lossy(&data[7..])
.trim_end_matches('\0')
.to_string()
} else {
String::new()
};
Ok((channel_idx, text))
}
// ─── Raw-bytes variants for typed message detection ────────────────────
/// Parse RESP_CONTACT_MSG_V3 returning raw payload bytes (not UTF-8 lossy).
/// Returns (sender_pubkey_prefix_hex, raw_payload_bytes, snr).
pub fn parse_contact_msg_v3_raw(data: &[u8]) -> Result<(String, Vec<u8>, i8)> {
if data.len() < 15 {
anyhow::bail!("Contact message too short: {} bytes", data.len());
}
let snr = data[0] as i8;
let pubkey_prefix = hex::encode(&data[3..9]);
let txt_type = data[10];
let text_start = if txt_type == 2 { 19 } else { 15 };
let payload = if data.len() > text_start {
data[text_start..].to_vec()
} else {
Vec::new()
};
Ok((pubkey_prefix, payload, snr))
}
/// Parse RESP_CONTACT_MSG returning raw payload bytes.
/// Returns (sender_pubkey_prefix_hex, raw_payload_bytes).
pub fn parse_contact_msg_v1_raw(data: &[u8]) -> Result<(String, Vec<u8>)> {
if data.len() < 12 {
anyhow::bail!("Contact message v1 too short: {} bytes", data.len());
}
let pubkey_prefix = hex::encode(&data[0..6]);
let txt_type = data[7];
let text_start = if txt_type == 2 { 16 } else { 12 };
let payload = if data.len() > text_start {
data[text_start..].to_vec()
} else {
Vec::new()
};
Ok((pubkey_prefix, payload))
}
/// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes.
/// Returns (channel_idx, raw_payload_bytes).
pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
if data.len() < 7 {
anyhow::bail!("Channel message too short: {} bytes", data.len());
}
let channel_idx = data[0];
let payload = if data.len() > 7 {
let mut p = data[7..].to_vec();
// Strip trailing NUL bytes
while p.last() == Some(&0) {
p.pop();
}
p
} else {
Vec::new()
};
Ok((channel_idx, payload))
}
/// Parse RESP_CHANNEL_MSG returning raw payload bytes.
/// Returns (channel_idx, raw_payload_bytes).
pub fn parse_channel_msg_v1_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
if data.len() < 7 {
anyhow::bail!("Channel message v1 too short: {} bytes", data.len());
}
let channel_idx = data[0];
let payload = if data.len() > 7 {
let mut p = data[7..].to_vec();
while p.last() == Some(&0) {
p.pop();
}
p
} else {
Vec::new()
};
Ok((channel_idx, payload))
}
/// Parse RESP_ERR (0x01). Returns descriptive error string.
pub fn parse_error(data: &[u8]) -> String {
if data.is_empty() {
return "Unknown device error".to_string();
}
match data[0] {
ERR_UNSUPPORTED_CMD => "Unsupported command".to_string(),
ERR_NOT_FOUND => "Not found".to_string(),
ERR_TABLE_FULL => "Contact table full".to_string(),
ERR_BAD_STATE => "Bad device state".to_string(),
ERR_FILE_IO => "Device file I/O error".to_string(),
ERR_ILLEGAL_ARG => "Illegal argument".to_string(),
code => format!("Device error code 0x{:02x}", code),
}
}
/// Check if a response code is a push notification (async event from device).
pub fn is_push_notification(code: u8) -> bool {
code >= 0x80
}
// ─── Archipelago identity wire format ───────────────────────────────────
/// Prefix for Archipelago identity broadcasts over mesh channel.
pub const ARCHY_IDENTITY_PREFIX: &str = "ARCHY:1:";
/// Encode an Archipelago identity announcement for channel broadcast.
/// Compact format: `ARCHY:2:{ed25519_pubkey_hex}:{x25519_pubkey_hex}`
/// DID is omitted to fit within 160-byte LoRa limit — receiver reconstructs did:key from ed25519 pubkey.
/// Total: 8 + 64 + 1 + 64 = 137 bytes (fits in 160).
pub fn encode_identity_broadcast(
_did: &str,
ed_pubkey_hex: &str,
x25519_pubkey_hex: &str,
) -> String {
format!("ARCHY:2:{}:{}", ed_pubkey_hex, x25519_pubkey_hex)
}
/// Try to parse an Archipelago identity from a received channel message.
/// Returns (did, ed25519_pubkey_hex, x25519_pubkey_hex) if valid.
///
/// Supports two formats:
/// - v2 (compact): `ARCHY:2:{ed25519_hex_64}:{x25519_hex_64}` — DID reconstructed from ed25519
/// - v1 (legacy): `ARCHY:1:{did}:{ed25519_hex_64}:{x25519_hex_64}`
pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> {
// Try v2 compact format first
if let Some(rest) = msg.strip_prefix("ARCHY:2:") {
let parts: Vec<&str> = rest.splitn(2, ':').collect();
if parts.len() != 2 {
return None;
}
let ed_pubkey = parts[0];
let x25519_pubkey = parts[1];
if ed_pubkey.len() != 64 || x25519_pubkey.len() != 64 {
return None;
}
if !ed_pubkey.chars().all(|c| c.is_ascii_hexdigit())
|| !x25519_pubkey.chars().all(|c| c.is_ascii_hexdigit())
{
return None;
}
// Reconstruct DID from ed25519 pubkey
let did = crate::identity::did_key_from_pubkey_hex(ed_pubkey).ok()?;
return Some((did, ed_pubkey.to_string(), x25519_pubkey.to_string()));
}
// Try v1 legacy format
let rest = msg.strip_prefix(ARCHY_IDENTITY_PREFIX)?;
let last_colon = rest.rfind(':')?;
let x25519_pubkey = &rest[last_colon + 1..];
if x25519_pubkey.len() != 64 || !x25519_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let before_x25519 = &rest[..last_colon];
let second_last_colon = before_x25519.rfind(':')?;
let ed_pubkey = &before_x25519[second_last_colon + 1..];
if ed_pubkey.len() != 64 || !ed_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let did = &before_x25519[..second_last_colon];
if !did.starts_with("did:key:z") {
return None;
}
Some((
did.to_string(),
ed_pubkey.to_string(),
x25519_pubkey.to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_frame() {
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
assert_eq!(frame[0], OUTBOUND_MARKER);
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 2);
assert_eq!(frame[3], CMD_DEVICE_QUERY);
assert_eq!(frame[4], PROTOCOL_VERSION);
}
#[test]
fn test_decode_frame_complete() -> Result<()> {
// Simulate an inbound frame: < + len(2) + [RESP_OK]
let buf = vec![INBOUND_MARKER, 0x01, 0x00, RESP_OK];
let frame =
decode_frame(&buf).ok_or_else(|| anyhow::anyhow!("failed to parse complete frame"))?;
assert_eq!(frame.code, RESP_OK);
assert!(frame.data.is_empty());
assert_eq!(frame.bytes_consumed, 4);
Ok(())
}
#[test]
fn test_decode_frame_with_data() -> Result<()> {
// < + len(5) + [RESP_SELF_INFO, 0x01, 0x02, 0x03, 0x04]
let buf = vec![
INBOUND_MARKER,
0x05,
0x00,
RESP_SELF_INFO,
0x01,
0x02,
0x03,
0x04,
];
let frame =
decode_frame(&buf).ok_or_else(|| anyhow::anyhow!("failed to parse frame with data"))?;
assert_eq!(frame.code, RESP_SELF_INFO);
assert_eq!(frame.data, vec![0x01, 0x02, 0x03, 0x04]);
assert_eq!(frame.bytes_consumed, 8);
Ok(())
}
#[test]
fn test_decode_frame_incomplete() {
let buf = vec![INBOUND_MARKER, 0x05, 0x00, RESP_OK]; // says 5 bytes but only 1
assert!(decode_frame(&buf).is_none());
}
#[test]
fn test_decode_frame_no_marker() {
let buf = vec![0xFF, 0x01, 0x00, RESP_OK];
assert!(decode_frame(&buf).is_none());
}
#[test]
fn test_decode_frame_skips_garbage() -> Result<()> {
// Garbage bytes before the actual frame
let buf = vec![0xFF, 0xAA, INBOUND_MARKER, 0x01, 0x00, RESP_OK];
let frame = decode_frame(&buf)
.ok_or_else(|| anyhow::anyhow!("failed to skip garbage and parse frame"))?;
assert_eq!(frame.code, RESP_OK);
assert_eq!(frame.bytes_consumed, 6); // 2 garbage + 4 frame
Ok(())
}
#[test]
fn test_build_device_query() {
let frame = build_device_query();
assert_eq!(frame[0], OUTBOUND_MARKER);
assert_eq!(frame[3], CMD_DEVICE_QUERY);
assert_eq!(frame[4], PROTOCOL_VERSION);
}
#[test]
fn test_build_app_start() -> Result<()> {
// Frame layout: [0: '>'][1-2: len LE][3: CMD][4: VERSION][5..: padded name]
let frame = build_app_start("Archipelago");
assert_eq!(frame[3], CMD_APP_START);
assert_eq!(frame[4], PROTOCOL_VERSION);
let name = &frame[5..];
assert_eq!(
std::str::from_utf8(name)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in app name: {}", e))?,
"Archipelago"
);
Ok(())
}
#[test]
fn test_build_set_device_time() {
let ts: u64 = 1710600000;
let frame = build_set_device_time(ts);
assert_eq!(frame[3], CMD_SET_DEVICE_TIME);
let time_bytes = &frame[4..8];
assert_eq!(
u32::from_le_bytes([time_bytes[0], time_bytes[1], time_bytes[2], time_bytes[3]]),
ts as u32
);
}
#[test]
fn test_build_send_text() -> Result<()> {
let dest: [u8; 6] = [0x00, 0x00, 0x00, 0x2A, 0x00, 0x00];
let frame = build_send_text(&dest, b"hello")?;
assert_eq!(frame[3], CMD_SEND_TXT_MSG);
Ok(())
}
#[test]
fn test_build_send_text_too_large() {
let dest: [u8; 6] = [0x00; 6];
let big = vec![0u8; MAX_MESSAGE_LEN + 1];
assert!(build_send_text(&dest, &big).is_err());
}
#[test]
fn test_build_send_channel_text() -> Result<()> {
let frame = build_send_channel_text(2, b"test")?;
// Frame: [marker][len_lo][len_hi][cmd][txt_type][channel][ts(4)][text]
assert_eq!(frame[3], CMD_SEND_CHANNEL_TXT_MSG);
assert_eq!(frame[4], 0); // txt_type
assert_eq!(frame[5], 2); // channel idx
// frame[6..10] = timestamp, non-deterministic
assert_eq!(&frame[10..], b"test");
Ok(())
}
#[test]
fn test_identity_broadcast_roundtrip() -> Result<()> {
// The v2 encoding drops the DID and the decoder reconstructs it
// deterministically from the ed25519 pubkey, so the roundtripped
// DID won't equal an arbitrary input DID. Derive what the decoder
// will produce and assert against that.
let ed_pub = "a".repeat(64);
let x25519_pub = "b".repeat(64);
let expected_did = crate::identity::did_key_from_pubkey_hex(&ed_pub)
.map_err(|e| anyhow::anyhow!("derive did: {}", e))?;
let encoded = encode_identity_broadcast(&expected_did, &ed_pub, &x25519_pub);
let (parsed_did, parsed_ed, parsed_x) = parse_identity_broadcast(&encoded)
.ok_or_else(|| anyhow::anyhow!("failed to parse identity broadcast"))?;
assert_eq!(parsed_did, expected_did);
assert_eq!(parsed_ed, ed_pub);
assert_eq!(parsed_x, x25519_pub);
Ok(())
}
#[test]
fn test_identity_broadcast_invalid() {
assert!(parse_identity_broadcast("not an identity").is_none());
assert!(parse_identity_broadcast("ARCHY:1:bad").is_none());
assert!(parse_identity_broadcast("ARCHY:1:did:key:z123:short:short").is_none());
}
#[test]
fn test_parse_error_codes() {
assert_eq!(parse_error(&[ERR_NOT_FOUND]), "Not found");
assert_eq!(parse_error(&[ERR_TABLE_FULL]), "Contact table full");
assert_eq!(parse_error(&[]), "Unknown device error");
assert!(parse_error(&[0xFF]).contains("0xff"));
}
#[test]
fn test_is_push_notification() {
assert!(is_push_notification(PUSH_NEW_CONTACT));
assert!(is_push_notification(PUSH_ACK));
assert!(is_push_notification(0x80));
assert!(!is_push_notification(RESP_OK));
assert!(!is_push_notification(RESP_DEVICE_INFO));
}
#[test]
fn test_parse_self_info() -> Result<()> {
let mut data = vec![0x2A, 0x00, 0x00, 0x00]; // node_id = 42
data.extend_from_slice(b"TestNode\0");
let (id, name) = parse_self_info(&data)?;
assert_eq!(id, 42);
assert_eq!(name, "TestNode");
Ok(())
}
#[test]
fn test_parse_self_info_too_short() {
assert!(parse_self_info(&[0x01, 0x02]).is_err());
}
}