lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 8a56141399b3e760162f9dd7e49c1e11a265c7f8
parent 0ea9e063e678e8fb3caea003acec9087d5b88794
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 01:10:16 +0000

simplex: restore legacy queue and new compatibility

- accept official unversioned queue uris and normalize them to canonical versioned output
- support legacy v6-v8 NEW encoding with A-prefixed basic auth and no post-v9 queue fields
- keep the v9, v15, and v17 NEW layouts explicit across the version gates
- add uri and wire tests for legacy parsing plus version-gated NEW compatibility

Diffstat:
Mcrates/simplex-smp-proto/src/uri.rs | 133++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/simplex-smp-proto/src/wire.rs | 162++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 234 insertions(+), 61 deletions(-)

diff --git a/crates/simplex-smp-proto/src/uri.rs b/crates/simplex-smp-proto/src/uri.rs @@ -1,5 +1,6 @@ use crate::error::RadrootsSimplexSmpProtoError; use crate::version::{ + RADROOTS_SIMPLEX_SMP_INITIAL_CLIENT_VERSION, RADROOTS_SIMPLEX_SMP_SERVER_HOSTNAMES_CLIENT_VERSION, RADROOTS_SIMPLEX_SMP_SHORT_LINKS_CLIENT_VERSION, RadrootsSimplexSmpVersionRange, }; @@ -66,66 +67,83 @@ impl RadrootsSimplexSmpQueueUri { validate_base64_url("sender_id", &sender_id)?; let (fragment_dh_public_key, query) = parse_fragment_query(fragment, value)?; - let mut version_range: Option<RadrootsSimplexSmpVersionRange> = None; + let mut version_range = if query.is_none() { + Some(RadrootsSimplexSmpVersionRange::single( + RADROOTS_SIMPLEX_SMP_INITIAL_CLIENT_VERSION, + )) + } else { + None + }; let mut recipient_dh_public_key: Option<String> = fragment_dh_public_key; let mut queue_mode: Option<RadrootsSimplexSmpQueueMode> = None; let mut extra_hosts: Option<Vec<String>> = None; - for pair in query.split('&') { - if pair.is_empty() { - continue; - } + if let Some(query) = query { + version_range = None; + for pair in query.split('&') { + if pair.is_empty() { + continue; + } - let (key, raw_value) = pair - .split_once('=') - .ok_or_else(|| RadrootsSimplexSmpProtoError::InvalidUri(value.to_string()))?; + let (key, raw_value) = pair + .split_once('=') + .ok_or_else(|| RadrootsSimplexSmpProtoError::InvalidUri(value.to_string()))?; - match key { - "v" => { - version_range = Some(raw_value.parse()?); - } - "dh" => { - validate_base64_url("recipient_dh_public_key", raw_value)?; - if recipient_dh_public_key - .replace(raw_value.to_string()) - .is_some() - { - return Err(RadrootsSimplexSmpProtoError::InvalidUri(value.to_string())); + match key { + "v" => { + version_range = Some(raw_value.parse()?); } - } - "q" => { - let next_mode = match raw_value { - "m" => RadrootsSimplexSmpQueueMode::Messaging, - "c" => RadrootsSimplexSmpQueueMode::Contact, - _ => { + "dh" => { + validate_base64_url("recipient_dh_public_key", raw_value)?; + if recipient_dh_public_key + .replace(raw_value.to_string()) + .is_some() + { return Err(RadrootsSimplexSmpProtoError::InvalidUri( value.to_string(), )); } - }; - if queue_mode.replace(next_mode).is_some() { - return Err(RadrootsSimplexSmpProtoError::InvalidUri(value.to_string())); } - } - "k" if raw_value == "s" => { - if queue_mode - .replace(RadrootsSimplexSmpQueueMode::Messaging) - .is_some() - { - return Err(RadrootsSimplexSmpProtoError::InvalidUri(value.to_string())); + "q" => { + let next_mode = match raw_value { + "m" => RadrootsSimplexSmpQueueMode::Messaging, + "c" => RadrootsSimplexSmpQueueMode::Contact, + _ => { + return Err(RadrootsSimplexSmpProtoError::InvalidUri( + value.to_string(), + )); + } + }; + if queue_mode.replace(next_mode).is_some() { + return Err(RadrootsSimplexSmpProtoError::InvalidUri( + value.to_string(), + )); + } } - } - "srv" => { - if extra_hosts - .replace(parse_host_list(raw_value, value)?) - .is_some() - { + "k" if raw_value == "s" => { + if queue_mode + .replace(RadrootsSimplexSmpQueueMode::Messaging) + .is_some() + { + return Err(RadrootsSimplexSmpProtoError::InvalidUri( + value.to_string(), + )); + } + } + "srv" => { + if extra_hosts + .replace(parse_host_list(raw_value, value)?) + .is_some() + { + return Err(RadrootsSimplexSmpProtoError::InvalidUri( + value.to_string(), + )); + } + } + _ => { return Err(RadrootsSimplexSmpProtoError::InvalidUri(value.to_string())); } } - _ => { - return Err(RadrootsSimplexSmpProtoError::InvalidUri(value.to_string())); - } } } @@ -233,18 +251,22 @@ fn parse_server_address( fn parse_fragment_query<'a>( fragment: &'a str, original: &str, -) -> Result<(Option<String>, &'a str), RadrootsSimplexSmpProtoError> { +) -> Result<(Option<String>, Option<&'a str>), RadrootsSimplexSmpProtoError> { let fragment = fragment.strip_prefix('/').unwrap_or(fragment); if let Some(query) = fragment.strip_prefix('?') { - return Ok((None, query)); + return Ok((None, Some(query))); } if let Some((dh_public_key, query)) = fragment.split_once("/?") { validate_base64_url("recipient_dh_public_key", dh_public_key)?; - return Ok((Some(dh_public_key.to_string()), query)); + return Ok((Some(dh_public_key.to_string()), Some(query))); } if let Some((dh_public_key, query)) = fragment.split_once('?') { validate_base64_url("recipient_dh_public_key", dh_public_key)?; - return Ok((Some(dh_public_key.to_string()), query)); + return Ok((Some(dh_public_key.to_string()), Some(query))); + } + if !fragment.is_empty() { + validate_base64_url("recipient_dh_public_key", fragment)?; + return Ok((Some(fragment.to_string()), None)); } Err(RadrootsSimplexSmpProtoError::InvalidUri( original.to_string(), @@ -343,4 +365,19 @@ mod tests { "smp://YWJjZA@server1.example:5223/cXVldWU#/?v=1-3&dh=ZGhLZXk&k=s&srv=server2.example" ); } + + #[test] + fn parses_legacy_unversioned_queue_uri() { + let uri = + RadrootsSimplexSmpQueueUri::parse("smp://YWJjZA@server1.example/cXVldWU/#ZGhLZXk") + .unwrap(); + + assert_eq!(uri.version_range, RadrootsSimplexSmpVersionRange::single(1)); + assert_eq!(uri.recipient_dh_public_key, "ZGhLZXk"); + assert_eq!(uri.queue_mode, None); + assert_eq!( + uri.to_string(), + "smp://YWJjZA@server1.example/cXVldWU#/?v=1&dh=ZGhLZXk" + ); + } } diff --git a/crates/simplex-smp-proto/src/wire.rs b/crates/simplex-smp-proto/src/wire.rs @@ -1,7 +1,7 @@ use crate::error::RadrootsSimplexSmpProtoError; use crate::uri::RadrootsSimplexSmpQueueMode; use crate::version::{ - RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION, RADROOTS_SIMPLEX_SMP_NEW_NOTIFIER_CREDENTIALS_TRANSPORT_VERSION, RADROOTS_SIMPLEX_SMP_SENDER_AUTH_KEY_TRANSPORT_VERSION, RADROOTS_SIMPLEX_SMP_SERVICE_CERTS_TRANSPORT_VERSION, @@ -561,7 +561,7 @@ fn encode_new_request( request: &RadrootsSimplexSmpNewQueueRequest, transport_version: u16, ) -> Result<(), RadrootsSimplexSmpProtoError> { - if transport_version < RADROOTS_SIMPLEX_SMP_SENDER_AUTH_KEY_TRANSPORT_VERSION { + if transport_version < RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION { return Err(RadrootsSimplexSmpProtoError::UnsupportedTransportVersion( transport_version, )); @@ -571,10 +571,9 @@ fn encode_new_request( buffer.push(b' '); push_short_bytes(buffer, &request.recipient_auth_public_key)?; push_short_bytes(buffer, &request.recipient_dh_public_key)?; - push_maybe_string(buffer, request.basic_auth.as_deref())?; - buffer.push(encode_subscription_mode(request.subscription_mode)); - if transport_version >= RADROOTS_SIMPLEX_SMP_NEW_NOTIFIER_CREDENTIALS_TRANSPORT_VERSION { + push_maybe_string(buffer, request.basic_auth.as_deref())?; + buffer.push(encode_subscription_mode(request.subscription_mode)); push_maybe( buffer, request.queue_request_data.as_ref(), @@ -586,13 +585,20 @@ fn encode_new_request( encode_new_notifier_credentials, )?; } else if transport_version >= RADROOTS_SIMPLEX_SMP_SHORT_LINKS_TRANSPORT_VERSION { + push_maybe_string(buffer, request.basic_auth.as_deref())?; + buffer.push(encode_subscription_mode(request.subscription_mode)); push_maybe( buffer, request.queue_request_data.as_ref(), encode_queue_request_data, )?; - } else { + } else if transport_version >= RADROOTS_SIMPLEX_SMP_SENDER_AUTH_KEY_TRANSPORT_VERSION { + push_maybe_string(buffer, request.basic_auth.as_deref())?; + buffer.push(encode_subscription_mode(request.subscription_mode)); buffer.push(encode_bool(request.sender_can_secure())); + } else { + push_legacy_basic_auth(buffer, request.basic_auth.as_deref())?; + buffer.push(encode_subscription_mode(request.subscription_mode)); } Ok(()) @@ -602,7 +608,7 @@ fn decode_new_request( cursor: &mut Cursor<'_>, transport_version: u16, ) -> Result<RadrootsSimplexSmpNewQueueRequest, RadrootsSimplexSmpProtoError> { - if transport_version < RADROOTS_SIMPLEX_SMP_SENDER_AUTH_KEY_TRANSPORT_VERSION { + if transport_version < RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION { return Err(RadrootsSimplexSmpProtoError::UnsupportedTransportVersion( transport_version, )); @@ -610,24 +616,38 @@ fn decode_new_request( let recipient_auth_public_key = cursor.read_short_bytes()?; let recipient_dh_public_key = cursor.read_short_bytes()?; - let basic_auth = cursor.read_maybe_string()?; - let subscription_mode = decode_subscription_mode(cursor.read_byte()?)?; - let (queue_request_data, notifier_credentials) = + let (basic_auth, subscription_mode, queue_request_data, notifier_credentials) = if transport_version >= RADROOTS_SIMPLEX_SMP_NEW_NOTIFIER_CREDENTIALS_TRANSPORT_VERSION { ( + cursor.read_maybe_string()?, + decode_subscription_mode(cursor.read_byte()?)?, cursor.read_maybe(decode_queue_request_data)?, cursor.read_maybe(decode_new_notifier_credentials)?, ) } else if transport_version >= RADROOTS_SIMPLEX_SMP_SHORT_LINKS_TRANSPORT_VERSION { - (cursor.read_maybe(decode_queue_request_data)?, None) - } else { + ( + cursor.read_maybe_string()?, + decode_subscription_mode(cursor.read_byte()?)?, + cursor.read_maybe(decode_queue_request_data)?, + None, + ) + } else if transport_version >= RADROOTS_SIMPLEX_SMP_SENDER_AUTH_KEY_TRANSPORT_VERSION { + let basic_auth = cursor.read_maybe_string()?; + let subscription_mode = decode_subscription_mode(cursor.read_byte()?)?; let sender_can_secure = decode_bool(cursor.read_byte()?)?; let queue_request_data = Some(if sender_can_secure { RadrootsSimplexSmpQueueRequestData::Messaging(None) } else { RadrootsSimplexSmpQueueRequestData::Contact(None) }); - (queue_request_data, None) + (basic_auth, subscription_mode, queue_request_data, None) + } else { + ( + cursor.read_legacy_basic_auth()?, + decode_subscription_mode(cursor.read_byte()?)?, + None, + None, + ) }; Ok(RadrootsSimplexSmpNewQueueRequest { @@ -1110,6 +1130,20 @@ fn push_maybe_string( } } +fn push_legacy_basic_auth( + buffer: &mut Vec<u8>, + value: Option<&str>, +) -> Result<(), RadrootsSimplexSmpProtoError> { + match value { + None => Ok(()), + Some(value) => { + validate_basic_auth(value)?; + buffer.push(b'A'); + push_short_bytes(buffer, value.as_bytes()) + } + } +} + fn validate_basic_auth(value: &str) -> Result<(), RadrootsSimplexSmpProtoError> { if value .bytes() @@ -1184,6 +1218,22 @@ impl<'a> Cursor<'a> { }) } + fn read_legacy_basic_auth(&mut self) -> Result<Option<String>, RadrootsSimplexSmpProtoError> { + match self.bytes.get(self.offset).copied() { + Some(b'A') => { + self.offset += 1; + let value = self.read_short_bytes()?; + let string = String::from_utf8(value).map_err(|error| { + RadrootsSimplexSmpProtoError::InvalidUtf8(error.to_string()) + })?; + validate_basic_auth(&string)?; + Ok(Some(string)) + } + Some(_) => Ok(None), + None => Err(RadrootsSimplexSmpProtoError::UnexpectedEof), + } + } + fn read_maybe<T, F>(&mut self, decode: F) -> Result<Option<T>, RadrootsSimplexSmpProtoError> where F: FnOnce(&mut Self) -> Result<T, RadrootsSimplexSmpProtoError>, @@ -1270,6 +1320,33 @@ mod tests { } #[test] + fn round_trips_v6_new_command_transmission() { + let transmission = RadrootsSimplexSmpCommandTransmission { + authorization: vec![1, 2, 3], + correlation_id: Some(correlation_id(7)), + entity_id: Vec::new(), + command: RadrootsSimplexSmpCommand::New(RadrootsSimplexSmpNewQueueRequest { + recipient_auth_public_key: vec![0x01, 0x02, 0x03], + recipient_dh_public_key: vec![0x04, 0x05], + basic_auth: Some("server-pass".to_string()), + subscription_mode: RadrootsSimplexSmpSubscriptionMode::Subscribe, + queue_request_data: None, + notifier_credentials: None, + }), + }; + + let encoded = transmission + .encode_for_version(RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION) + .unwrap(); + let decoded = RadrootsSimplexSmpCommandTransmission::decode_for_version( + RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION, + &encoded, + ) + .unwrap(); + assert_eq!(decoded, transmission); + } + + #[test] fn round_trips_send_command_transmission() { let transmission = RadrootsSimplexSmpCommandTransmission { authorization: Vec::new(), @@ -1290,6 +1367,41 @@ mod tests { } #[test] + fn round_trips_v15_new_command_transmission() { + let transmission = RadrootsSimplexSmpCommandTransmission { + authorization: vec![1, 2, 3], + correlation_id: Some(correlation_id(7)), + entity_id: Vec::new(), + command: RadrootsSimplexSmpCommand::New(RadrootsSimplexSmpNewQueueRequest { + recipient_auth_public_key: vec![0x01, 0x02, 0x03], + recipient_dh_public_key: vec![0x04, 0x05], + basic_auth: Some("server-pass".to_string()), + subscription_mode: RadrootsSimplexSmpSubscriptionMode::Subscribe, + queue_request_data: Some(RadrootsSimplexSmpQueueRequestData::Messaging(Some( + RadrootsSimplexSmpMessagingQueueRequest { + sender_id: vec![0x10, 0x11], + link_data: RadrootsSimplexSmpQueueLinkData { + fixed_data: vec![0xaa, 0xbb], + user_data: vec![0xcc, 0xdd, 0xee], + }, + }, + ))), + notifier_credentials: None, + }), + }; + + let encoded = transmission + .encode_for_version(RADROOTS_SIMPLEX_SMP_SHORT_LINKS_TRANSPORT_VERSION) + .unwrap(); + let decoded = RadrootsSimplexSmpCommandTransmission::decode_for_version( + RADROOTS_SIMPLEX_SMP_SHORT_LINKS_TRANSPORT_VERSION, + &encoded, + ) + .unwrap(); + assert_eq!(decoded, transmission); + } + + #[test] fn round_trips_current_ids_broker_transmission() { let transmission = RadrootsSimplexSmpBrokerTransmission { authorization: Vec::new(), @@ -1376,4 +1488,28 @@ mod tests { let decoded = RadrootsSimplexSmpBrokerTransmission::decode(&encoded).unwrap(); assert_eq!(decoded, transmission); } + + #[test] + fn v6_new_command_uses_legacy_basic_auth_layout() { + let command = RadrootsSimplexSmpCommand::New(RadrootsSimplexSmpNewQueueRequest { + recipient_auth_public_key: vec![0x01, 0x02, 0x03], + recipient_dh_public_key: vec![0x04, 0x05], + basic_auth: Some("server-pass".to_string()), + subscription_mode: RadrootsSimplexSmpSubscriptionMode::Subscribe, + queue_request_data: Some(RadrootsSimplexSmpQueueRequestData::Messaging(None)), + notifier_credentials: Some(RadrootsSimplexSmpNewNotifierCredentials { + notifier_auth_public_key: vec![0x21, 0x22], + recipient_notification_dh_public_key: vec![0x23, 0x24], + }), + }); + + let encoded = command + .encode_for_version(RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION) + .unwrap(); + + assert_eq!( + encoded, + b"NEW \x03\x01\x02\x03\x02\x04\x05A\x0bserver-passS".to_vec() + ); + } }