forked from Keelrleek/SoulStream
Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "soulseek-proto"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bytes.workspace = true
|
||||
thiserror.workspace = true
|
||||
flate2 = "1"
|
||||
md-5 = "0.10"
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Distributed network messages (connection type "D"), single-byte codes.
|
||||
//! We join as a child: connect to a parent, receive relayed searches, and
|
||||
//! report our branch level/root.
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::wire::{Reader, Writer};
|
||||
use crate::Result;
|
||||
|
||||
pub mod code {
|
||||
pub const PING: u8 = 0;
|
||||
pub const SEARCH_REQUEST: u8 = 3;
|
||||
pub const BRANCH_LEVEL: u8 = 4;
|
||||
pub const BRANCH_ROOT: u8 = 5;
|
||||
pub const CHILD_DEPTH: u8 = 7;
|
||||
pub const EMBEDDED_MESSAGE: u8 = 93;
|
||||
}
|
||||
|
||||
// ---- outgoing (to parent / children) ----
|
||||
|
||||
pub fn branch_level(level: u32) -> Vec<u8> {
|
||||
Writer::new().u32(level).finish_u8(code::BRANCH_LEVEL)
|
||||
}
|
||||
|
||||
pub fn branch_root(root_username: &str) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.string(root_username)
|
||||
.finish_u8(code::BRANCH_ROOT)
|
||||
}
|
||||
|
||||
pub fn ping() -> Vec<u8> {
|
||||
Writer::new().finish_u8(code::PING)
|
||||
}
|
||||
|
||||
/// Relay a search to our children.
|
||||
pub fn search_request(username: &str, token: u32, query: &str) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.u32(0)
|
||||
.string(username)
|
||||
.u32(token)
|
||||
.string(query)
|
||||
.finish_u8(code::SEARCH_REQUEST)
|
||||
}
|
||||
|
||||
// ---- incoming ----
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DistributedEvent {
|
||||
/// A search to match against our shares and reply to `username`.
|
||||
SearchRequest {
|
||||
username: String,
|
||||
token: u32,
|
||||
query: String,
|
||||
},
|
||||
BranchLevel(u32),
|
||||
BranchRoot(String),
|
||||
Ping,
|
||||
Unhandled {
|
||||
code: u8,
|
||||
},
|
||||
}
|
||||
|
||||
/// Decode a distributed frame (code byte + payload). Handles the
|
||||
/// `EmbeddedMessage` (93) wrapper by unwrapping to its inner distributed
|
||||
/// message.
|
||||
pub fn decode(frame: Bytes) -> Result<DistributedEvent> {
|
||||
let mut r = Reader::new(frame);
|
||||
let code = r.u8()?;
|
||||
decode_inner(code, &mut r)
|
||||
}
|
||||
|
||||
fn decode_inner(code: u8, r: &mut Reader) -> Result<DistributedEvent> {
|
||||
Ok(match code {
|
||||
code::EMBEDDED_MESSAGE => {
|
||||
// [u8 inner_code][inner payload]
|
||||
let inner = r.u8()?;
|
||||
return decode_inner(inner, r);
|
||||
}
|
||||
code::SEARCH_REQUEST => {
|
||||
let _unknown = r.u32()?;
|
||||
let username = r.string()?;
|
||||
let token = r.u32()?;
|
||||
let query = r.string()?;
|
||||
DistributedEvent::SearchRequest {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
}
|
||||
}
|
||||
code::BRANCH_LEVEL => DistributedEvent::BranchLevel(r.u32()?),
|
||||
code::BRANCH_ROOT => DistributedEvent::BranchRoot(r.string()?),
|
||||
code::PING => DistributedEvent::Ping,
|
||||
other => DistributedEvent::Unhandled { code: other },
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decode_search_request() {
|
||||
let msg = search_request("alice", 42, "the beatles");
|
||||
let ev = decode(Bytes::copy_from_slice(&msg[4..])).unwrap();
|
||||
match ev {
|
||||
DistributedEvent::SearchRequest {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
} => {
|
||||
assert_eq!(
|
||||
(username.as_str(), token, query.as_str()),
|
||||
("alice", 42, "the beatles")
|
||||
);
|
||||
}
|
||||
other => panic!("wrong: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_embedded_wrapper() {
|
||||
// Server EmbeddedMessage delivers [93][3][search payload].
|
||||
let inner = search_request("bob", 7, "query");
|
||||
// inner[4] is the code byte (3); inner[5..] is the payload.
|
||||
let mut framed = vec![code::EMBEDDED_MESSAGE];
|
||||
framed.extend_from_slice(&inner[4..]); // code 3 + payload
|
||||
let ev = decode(Bytes::from(framed)).unwrap();
|
||||
match ev {
|
||||
DistributedEvent::SearchRequest {
|
||||
username, query, ..
|
||||
} => {
|
||||
assert_eq!((username.as_str(), query.as_str()), ("bob", "query"));
|
||||
}
|
||||
other => panic!("wrong: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Soulseek protocol messages and binary codecs.
|
||||
//!
|
||||
//! Wire format: every message is `[u32 length][code][payload]`, little-endian
|
||||
//! throughout. Server and peer messages use a `u32` code; connection
|
||||
//! initialization messages (PeerInit / PierceFirewall) use a single-byte code.
|
||||
//! Strings are `[u32 len][bytes]`, UTF-8 with Latin-1 fallback on decode.
|
||||
//! Search responses and browse payloads are zlib-compressed after the code.
|
||||
//!
|
||||
//! Reference: Soulseek.NET `src/Messaging/**` and the Nicotine+ protocol docs.
|
||||
|
||||
pub mod dist;
|
||||
pub mod peer;
|
||||
pub mod server;
|
||||
pub mod types;
|
||||
pub mod wire;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProtoError {
|
||||
#[error("message payload ended unexpectedly")]
|
||||
UnexpectedEof,
|
||||
#[error("zlib decompression failed: {0}")]
|
||||
Zlib(#[from] std::io::Error),
|
||||
#[error("message too large: {0} bytes")]
|
||||
TooLarge(usize),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ProtoError>;
|
||||
|
||||
/// Sanity cap on a single message frame (search responses from big shares
|
||||
/// run a few hundred KB; browse responses can reach tens of MB).
|
||||
pub const MAX_MESSAGE_SIZE: usize = 64 * 1024 * 1024;
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Peer connection messages: single-byte-code initialization handshake,
|
||||
//! then u32-code peer messages.
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::types::{Directory, File, SearchResponse};
|
||||
use crate::wire::{Reader, Writer};
|
||||
use crate::{ProtoError, Result};
|
||||
|
||||
pub mod init_code {
|
||||
pub const PIERCE_FIREWALL: u8 = 0;
|
||||
pub const PEER_INIT: u8 = 1;
|
||||
}
|
||||
|
||||
pub mod code {
|
||||
pub const BROWSE_REQUEST: u32 = 4;
|
||||
pub const BROWSE_RESPONSE: u32 = 5;
|
||||
pub const SEARCH_RESPONSE: u32 = 9;
|
||||
pub const USER_INFO_REQUEST: u32 = 15;
|
||||
pub const USER_INFO_RESPONSE: u32 = 16;
|
||||
pub const FOLDER_CONTENTS_REQUEST: u32 = 36;
|
||||
pub const FOLDER_CONTENTS_RESPONSE: u32 = 37;
|
||||
pub const TRANSFER_REQUEST: u32 = 40;
|
||||
pub const TRANSFER_RESPONSE: u32 = 41;
|
||||
pub const QUEUE_DOWNLOAD: u32 = 43;
|
||||
pub const PLACE_IN_QUEUE_RESPONSE: u32 = 44;
|
||||
pub const UPLOAD_FAILED: u32 = 46;
|
||||
pub const UPLOAD_DENIED: u32 = 50;
|
||||
pub const PLACE_IN_QUEUE_REQUEST: u32 = 51;
|
||||
}
|
||||
|
||||
/// Connection types carried by PeerInit / ConnectToPeer.
|
||||
pub const CONN_TYPE_PEER: &str = "P";
|
||||
pub const CONN_TYPE_TRANSFER: &str = "F";
|
||||
pub const CONN_TYPE_DISTRIBUTED: &str = "D";
|
||||
|
||||
/// TransferRequest direction values.
|
||||
pub const DIRECTION_DOWNLOAD: u32 = 0;
|
||||
pub const DIRECTION_UPLOAD: u32 = 1;
|
||||
|
||||
// ---- initialization (u8 code) ----
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InitMessage {
|
||||
PierceFirewall {
|
||||
token: u32,
|
||||
},
|
||||
PeerInit {
|
||||
username: String,
|
||||
conn_type: String,
|
||||
token: u32,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn pierce_firewall(token: u32) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.u32(token)
|
||||
.finish_u8(init_code::PIERCE_FIREWALL)
|
||||
}
|
||||
|
||||
pub fn peer_init(username: &str, conn_type: &str, token: u32) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.string(username)
|
||||
.string(conn_type)
|
||||
.u32(token)
|
||||
.finish_u8(init_code::PEER_INIT)
|
||||
}
|
||||
|
||||
/// Decode the first frame on a fresh incoming connection.
|
||||
/// `frame` is the full frame body (code byte included, length header not).
|
||||
pub fn decode_init(frame: Bytes) -> Result<InitMessage> {
|
||||
let mut r = Reader::new(frame);
|
||||
match r.u8()? {
|
||||
init_code::PIERCE_FIREWALL => Ok(InitMessage::PierceFirewall { token: r.u32()? }),
|
||||
init_code::PEER_INIT => Ok(InitMessage::PeerInit {
|
||||
username: r.string()?,
|
||||
conn_type: r.string()?,
|
||||
token: r.u32()?,
|
||||
}),
|
||||
_ => Err(ProtoError::UnexpectedEof),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- peer messages (u32 code) ----
|
||||
|
||||
/// Ask a peer to send us a file (modern flow; direction=download, size 0).
|
||||
pub fn transfer_request_download(token: u32, filename: &str) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.u32(DIRECTION_DOWNLOAD)
|
||||
.u32(token)
|
||||
.string(filename)
|
||||
.u64(0)
|
||||
.finish_u32(code::TRANSFER_REQUEST)
|
||||
}
|
||||
|
||||
/// Offer to upload one of our files to a peer (direction=upload).
|
||||
pub fn transfer_request_upload(token: u32, filename: &str, size: u64) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.u32(DIRECTION_UPLOAD)
|
||||
.u32(token)
|
||||
.string(filename)
|
||||
.u64(size)
|
||||
.finish_u32(code::TRANSFER_REQUEST)
|
||||
}
|
||||
|
||||
/// Accept a peer's upload offer (their TransferRequest with direction=upload).
|
||||
pub fn transfer_response_allowed(token: u32, size: u64) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.u32(token)
|
||||
.u8(1)
|
||||
.u64(size)
|
||||
.finish_u32(code::TRANSFER_RESPONSE)
|
||||
}
|
||||
|
||||
pub fn transfer_response_rejected(token: u32, reason: &str) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.u32(token)
|
||||
.u8(0)
|
||||
.string(reason)
|
||||
.finish_u32(code::TRANSFER_RESPONSE)
|
||||
}
|
||||
|
||||
/// Legacy enqueue message; some clients expect it before TransferRequest.
|
||||
pub fn queue_download(filename: &str) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.string(filename)
|
||||
.finish_u32(code::QUEUE_DOWNLOAD)
|
||||
}
|
||||
|
||||
pub fn place_in_queue_request(filename: &str) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.string(filename)
|
||||
.finish_u32(code::PLACE_IN_QUEUE_REQUEST)
|
||||
}
|
||||
|
||||
pub fn place_in_queue_response(filename: &str, place: u32) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.string(filename)
|
||||
.u32(place)
|
||||
.finish_u32(code::PLACE_IN_QUEUE_RESPONSE)
|
||||
}
|
||||
|
||||
pub fn upload_denied(filename: &str, reason: &str) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.string(filename)
|
||||
.string(reason)
|
||||
.finish_u32(code::UPLOAD_DENIED)
|
||||
}
|
||||
|
||||
// ---- responder-side encoders (we answer other peers' requests) ----
|
||||
|
||||
/// A search reply we send to a searcher (code 9, zlib-compressed).
|
||||
pub fn search_response(
|
||||
username: &str,
|
||||
token: u32,
|
||||
files: &[File],
|
||||
has_free_upload_slot: bool,
|
||||
upload_speed: u32,
|
||||
queue_length: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut w = Writer::new()
|
||||
.string(username)
|
||||
.u32(token)
|
||||
.u32(files.len() as u32);
|
||||
for f in files {
|
||||
w = f.write(w);
|
||||
}
|
||||
w = w
|
||||
.bool(has_free_upload_slot)
|
||||
.u32(upload_speed)
|
||||
.u32(queue_length)
|
||||
.u32(0) // unknown, for compatibility
|
||||
.u32(0); // locked file count
|
||||
w.finish_u32_compressed(code::SEARCH_RESPONSE)
|
||||
}
|
||||
|
||||
/// Our full shared directory listing (code 5, zlib-compressed).
|
||||
pub fn browse_response(dirs: &[Directory]) -> Vec<u8> {
|
||||
let mut w = Writer::new().u32(dirs.len() as u32);
|
||||
for d in dirs {
|
||||
w = d.write(w);
|
||||
}
|
||||
w = w.u32(0).u32(0); // unknown + locked directory count
|
||||
w.finish_u32_compressed(code::BROWSE_RESPONSE)
|
||||
}
|
||||
|
||||
/// Contents of a single requested folder (code 37, zlib-compressed).
|
||||
pub fn folder_contents_response(token: u32, folder: &str, dir: &Directory) -> Vec<u8> {
|
||||
let w = Writer::new().u32(token).string(folder).u32(1);
|
||||
let w = dir.write(w);
|
||||
w.finish_u32_compressed(code::FOLDER_CONTENTS_RESPONSE)
|
||||
}
|
||||
|
||||
/// Our user-info card (code 16).
|
||||
pub fn user_info_response(
|
||||
description: &str,
|
||||
upload_slots: u32,
|
||||
queue_length: u32,
|
||||
has_free_upload_slot: bool,
|
||||
) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.string(description)
|
||||
.u8(0) // has picture = false
|
||||
.u32(upload_slots)
|
||||
.u32(queue_length)
|
||||
.bool(has_free_upload_slot)
|
||||
.finish_u32(code::USER_INFO_RESPONSE)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PeerEvent {
|
||||
SearchResponse(SearchResponse),
|
||||
/// Peer is ready to send us a file (direction=upload) or wants one
|
||||
/// from us (direction=download → an upload request we service).
|
||||
TransferRequest {
|
||||
direction: u32,
|
||||
token: u32,
|
||||
filename: String,
|
||||
size: u64,
|
||||
},
|
||||
/// Answer to our TransferRequest.
|
||||
TransferResponse {
|
||||
token: u32,
|
||||
allowed: bool,
|
||||
size: u64,
|
||||
message: String,
|
||||
},
|
||||
PlaceInQueueResponse {
|
||||
filename: String,
|
||||
place: u32,
|
||||
},
|
||||
UploadDenied {
|
||||
filename: String,
|
||||
reason: String,
|
||||
},
|
||||
UploadFailed {
|
||||
filename: String,
|
||||
},
|
||||
// Requests other peers make of us (M5 responder):
|
||||
BrowseRequest,
|
||||
UserInfoRequest,
|
||||
FolderContentsRequest {
|
||||
token: u32,
|
||||
folder: String,
|
||||
},
|
||||
/// Peer asks us to queue an upload of one of our files to them.
|
||||
QueueDownload {
|
||||
filename: String,
|
||||
},
|
||||
PlaceInQueueRequest {
|
||||
filename: String,
|
||||
},
|
||||
Unhandled {
|
||||
code: u32,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn decode(code: u32, payload: Bytes) -> Result<PeerEvent> {
|
||||
// Search responses carry a zlib payload; handle before Reader dispatch.
|
||||
if code == code::SEARCH_RESPONSE {
|
||||
return Ok(PeerEvent::SearchResponse(decode_search_response(payload)?));
|
||||
}
|
||||
let mut r = Reader::new(payload);
|
||||
Ok(match code {
|
||||
code::TRANSFER_REQUEST => {
|
||||
let direction = r.u32()?;
|
||||
let token = r.u32()?;
|
||||
let filename = r.string()?;
|
||||
let size = if r.remaining() >= 8 { r.u64()? } else { 0 };
|
||||
PeerEvent::TransferRequest {
|
||||
direction,
|
||||
token,
|
||||
filename,
|
||||
size,
|
||||
}
|
||||
}
|
||||
code::TRANSFER_RESPONSE => {
|
||||
let token = r.u32()?;
|
||||
let allowed = r.bool()?;
|
||||
if allowed {
|
||||
let size = if r.remaining() >= 8 { r.u64()? } else { 0 };
|
||||
PeerEvent::TransferResponse {
|
||||
token,
|
||||
allowed,
|
||||
size,
|
||||
message: String::new(),
|
||||
}
|
||||
} else {
|
||||
let message = r.string().unwrap_or_default();
|
||||
PeerEvent::TransferResponse {
|
||||
token,
|
||||
allowed,
|
||||
size: 0,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
code::PLACE_IN_QUEUE_RESPONSE => PeerEvent::PlaceInQueueResponse {
|
||||
filename: r.string()?,
|
||||
place: r.u32()?,
|
||||
},
|
||||
code::UPLOAD_DENIED => PeerEvent::UploadDenied {
|
||||
filename: r.string()?,
|
||||
reason: r.string().unwrap_or_default(),
|
||||
},
|
||||
code::UPLOAD_FAILED => PeerEvent::UploadFailed {
|
||||
filename: r.string()?,
|
||||
},
|
||||
code::BROWSE_REQUEST => PeerEvent::BrowseRequest,
|
||||
code::USER_INFO_REQUEST => PeerEvent::UserInfoRequest,
|
||||
code::FOLDER_CONTENTS_REQUEST => PeerEvent::FolderContentsRequest {
|
||||
token: r.u32()?,
|
||||
folder: r.string()?,
|
||||
},
|
||||
code::QUEUE_DOWNLOAD => PeerEvent::QueueDownload {
|
||||
filename: r.string()?,
|
||||
},
|
||||
code::PLACE_IN_QUEUE_REQUEST => PeerEvent::PlaceInQueueRequest {
|
||||
filename: r.string()?,
|
||||
},
|
||||
other => PeerEvent::Unhandled { code: other },
|
||||
})
|
||||
}
|
||||
|
||||
/// Payload is zlib-compressed: username, token, files, slot/speed/queue,
|
||||
/// optional unknown u32, optional locked file list.
|
||||
fn decode_search_response(payload: Bytes) -> Result<SearchResponse> {
|
||||
let mut r = Reader::new(payload);
|
||||
r.decompress()?;
|
||||
|
||||
let username = r.string()?;
|
||||
let token = r.u32()?;
|
||||
let file_count = r.u32()?.min(100_000);
|
||||
let mut files = Vec::with_capacity(file_count.min(4096) as usize);
|
||||
for _ in 0..file_count {
|
||||
files.push(File::read(&mut r)?);
|
||||
}
|
||||
let has_free_upload_slot = r.bool()?;
|
||||
let upload_speed = r.u32()?;
|
||||
let queue_length = r.u32()?;
|
||||
if r.has_more() {
|
||||
let _unknown = r.u32()?;
|
||||
}
|
||||
let mut locked_files = Vec::new();
|
||||
if r.has_more() {
|
||||
let locked_count = r.u32()?.min(100_000);
|
||||
for _ in 0..locked_count {
|
||||
locked_files.push(File::read(&mut r)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SearchResponse {
|
||||
username,
|
||||
token,
|
||||
files,
|
||||
has_free_upload_slot,
|
||||
upload_speed,
|
||||
queue_length,
|
||||
locked_files,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use flate2::write::ZlibEncoder;
|
||||
use flate2::Compression;
|
||||
use std::io::Write as _;
|
||||
|
||||
fn zlib(data: &[u8]) -> Bytes {
|
||||
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||
enc.write_all(data).unwrap();
|
||||
Bytes::from(enc.finish().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_roundtrip() {
|
||||
let msg = peer_init("alice", "P", 42);
|
||||
let init = decode_init(Bytes::copy_from_slice(&msg[4..])).unwrap();
|
||||
match init {
|
||||
InitMessage::PeerInit {
|
||||
username,
|
||||
conn_type,
|
||||
token,
|
||||
} => {
|
||||
assert_eq!(
|
||||
(username.as_str(), conn_type.as_str(), token),
|
||||
("alice", "P", 42)
|
||||
);
|
||||
}
|
||||
other => panic!("wrong init: {other:?}"),
|
||||
}
|
||||
|
||||
let msg = pierce_firewall(7);
|
||||
match decode_init(Bytes::copy_from_slice(&msg[4..])).unwrap() {
|
||||
InitMessage::PierceFirewall { token } => assert_eq!(token, 7),
|
||||
other => panic!("wrong init: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_decode() {
|
||||
// Build the uncompressed body by hand.
|
||||
let body = Writer::new()
|
||||
.string("bob")
|
||||
.u32(555)
|
||||
.u32(1)
|
||||
// file entry
|
||||
.u8(1)
|
||||
.string("Music\\Artist\\Album\\01 - Song.flac")
|
||||
.u64(31_337_000)
|
||||
.string("flac")
|
||||
.u32(2)
|
||||
.u32(0)
|
||||
.u32(1024) // bitrate
|
||||
.u32(1)
|
||||
.u32(213) // duration
|
||||
// trailer
|
||||
.bool(true)
|
||||
.u32(500_000)
|
||||
.u32(0)
|
||||
.finish_u32(0);
|
||||
let compressed = zlib(&body[8..]); // strip len+code, compress payload only
|
||||
|
||||
let resp = decode_search_response(compressed).unwrap();
|
||||
assert_eq!(resp.username, "bob");
|
||||
assert_eq!(resp.token, 555);
|
||||
assert_eq!(resp.files.len(), 1);
|
||||
let f = &resp.files[0];
|
||||
assert_eq!(f.size, 31_337_000);
|
||||
assert_eq!(f.bitrate(), Some(1024));
|
||||
assert_eq!(f.duration_secs(), Some(213));
|
||||
assert!(resp.has_free_upload_slot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Server messages (`server.slsknet.org:2242`), u32 codes.
|
||||
|
||||
use bytes::Bytes;
|
||||
use md5::{Digest, Md5};
|
||||
use std::net::{Ipv4Addr, SocketAddrV4};
|
||||
|
||||
use crate::wire::{Reader, Writer};
|
||||
use crate::Result;
|
||||
|
||||
/// Client version fields sent at login. 160 is the protocol version spoken
|
||||
/// by current clients (Nicotine+ uses the same).
|
||||
pub const VERSION: u32 = 160;
|
||||
pub const MINOR_VERSION: u32 = 1;
|
||||
|
||||
pub mod code {
|
||||
pub const LOGIN: u32 = 1;
|
||||
pub const SET_LISTEN_PORT: u32 = 2;
|
||||
pub const GET_PEER_ADDRESS: u32 = 3;
|
||||
pub const WATCH_USER: u32 = 5;
|
||||
pub const CONNECT_TO_PEER: u32 = 18;
|
||||
pub const FILE_SEARCH: u32 = 26;
|
||||
pub const PING: u32 = 32;
|
||||
pub const SHARED_FOLDERS_AND_FILES: u32 = 35;
|
||||
pub const HAVE_NO_PARENTS: u32 = 71;
|
||||
pub const PARENT_MIN_SPEED: u32 = 83;
|
||||
pub const PARENT_SPEED_RATIO: u32 = 84;
|
||||
pub const EMBEDDED_MESSAGE: u32 = 93; // wraps a distributed search
|
||||
pub const ACCEPT_CHILDREN: u32 = 100;
|
||||
pub const NET_INFO: u32 = 102;
|
||||
pub const WISHLIST_INTERVAL: u32 = 104;
|
||||
pub const BRANCH_LEVEL: u32 = 126;
|
||||
pub const BRANCH_ROOT: u32 = 127;
|
||||
pub const EXCLUDED_SEARCH_PHRASES: u32 = 160;
|
||||
pub const CANNOT_CONNECT: u32 = 1001;
|
||||
}
|
||||
|
||||
// ---- outgoing ----
|
||||
|
||||
pub fn login(username: &str, password: &str) -> Vec<u8> {
|
||||
let hash = hex(Md5::digest(format!("{username}{password}").as_bytes()));
|
||||
Writer::new()
|
||||
.string(username)
|
||||
.string(password)
|
||||
.u32(VERSION)
|
||||
.string(&hash)
|
||||
.u32(MINOR_VERSION)
|
||||
.finish_u32(code::LOGIN)
|
||||
}
|
||||
|
||||
pub fn set_listen_port(port: u32) -> Vec<u8> {
|
||||
Writer::new().u32(port).finish_u32(code::SET_LISTEN_PORT)
|
||||
}
|
||||
|
||||
pub fn file_search(token: u32, query: &str) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.u32(token)
|
||||
.string(query)
|
||||
.finish_u32(code::FILE_SEARCH)
|
||||
}
|
||||
|
||||
pub fn shared_folders_and_files(dirs: u32, files: u32) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.u32(dirs)
|
||||
.u32(files)
|
||||
.finish_u32(code::SHARED_FOLDERS_AND_FILES)
|
||||
}
|
||||
|
||||
/// Tell the server whether we need a distributed parent.
|
||||
pub fn accept_children(accept: bool) -> Vec<u8> {
|
||||
Writer::new().bool(accept).finish_u32(code::ACCEPT_CHILDREN)
|
||||
}
|
||||
|
||||
pub fn branch_level(level: u32) -> Vec<u8> {
|
||||
Writer::new().u32(level).finish_u32(code::BRANCH_LEVEL)
|
||||
}
|
||||
|
||||
pub fn branch_root(root: &str) -> Vec<u8> {
|
||||
Writer::new().string(root).finish_u32(code::BRANCH_ROOT)
|
||||
}
|
||||
|
||||
pub fn ping() -> Vec<u8> {
|
||||
Writer::new().finish_u32(code::PING)
|
||||
}
|
||||
|
||||
pub fn get_peer_address(username: &str) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.string(username)
|
||||
.finish_u32(code::GET_PEER_ADDRESS)
|
||||
}
|
||||
|
||||
/// Ask the server to have `username` connect to us (indirect connection).
|
||||
pub fn connect_to_peer(token: u32, username: &str, conn_type: &str) -> Vec<u8> {
|
||||
Writer::new()
|
||||
.u32(token)
|
||||
.string(username)
|
||||
.string(conn_type)
|
||||
.finish_u32(code::CONNECT_TO_PEER)
|
||||
}
|
||||
|
||||
pub fn have_no_parents(value: bool) -> Vec<u8> {
|
||||
Writer::new().bool(value).finish_u32(code::HAVE_NO_PARENTS)
|
||||
}
|
||||
|
||||
// ---- incoming ----
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ServerEvent {
|
||||
LoginOk {
|
||||
greeting: String,
|
||||
own_ip: Ipv4Addr,
|
||||
is_supporter: bool,
|
||||
},
|
||||
LoginFailed {
|
||||
reason: String,
|
||||
},
|
||||
/// Another client wants a connection with us (or is answering our
|
||||
/// indirect request): connect to them and send PierceFirewall(token).
|
||||
ConnectToPeer {
|
||||
username: String,
|
||||
conn_type: String,
|
||||
addr: SocketAddrV4,
|
||||
token: u32,
|
||||
privileged: bool,
|
||||
},
|
||||
/// Our indirect connection request to `token` failed.
|
||||
CannotConnect {
|
||||
token: u32,
|
||||
username: Option<String>,
|
||||
},
|
||||
/// Answer to GetPeerAddress. A 0.0.0.0/port-0 address means offline.
|
||||
UserAddress {
|
||||
username: String,
|
||||
addr: SocketAddrV4,
|
||||
},
|
||||
ExcludedSearchPhrases(Vec<String>),
|
||||
/// Candidate distributed parents to connect to.
|
||||
NetInfo(Vec<(String, SocketAddrV4)>),
|
||||
/// A search relayed directly by the server (older path).
|
||||
ServerSearch {
|
||||
username: String,
|
||||
token: u32,
|
||||
query: String,
|
||||
},
|
||||
/// A distributed message wrapped by the server (code 93). The inner
|
||||
/// bytes are a distributed frame starting with its code byte — decode
|
||||
/// with `dist::decode`.
|
||||
Embedded(Bytes),
|
||||
Unhandled {
|
||||
code: u32,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn decode(code: u32, payload: Bytes) -> Result<ServerEvent> {
|
||||
let mut r = Reader::new(payload);
|
||||
Ok(match code {
|
||||
code::LOGIN => {
|
||||
if r.bool()? {
|
||||
let greeting = r.string()?;
|
||||
let own_ip = r.ipv4()?;
|
||||
// Trailing hash + supporter flag are absent on some servers.
|
||||
let _hash = r.string().unwrap_or_default();
|
||||
let is_supporter = r.bool().unwrap_or(false);
|
||||
ServerEvent::LoginOk {
|
||||
greeting,
|
||||
own_ip,
|
||||
is_supporter,
|
||||
}
|
||||
} else {
|
||||
ServerEvent::LoginFailed {
|
||||
reason: r.string()?,
|
||||
}
|
||||
}
|
||||
}
|
||||
code::CONNECT_TO_PEER => {
|
||||
let username = r.string()?;
|
||||
let conn_type = r.string()?;
|
||||
let ip = r.ipv4()?;
|
||||
let port = r.u32()?;
|
||||
let token = r.u32()?;
|
||||
let privileged = r.bool().unwrap_or(false);
|
||||
ServerEvent::ConnectToPeer {
|
||||
username,
|
||||
conn_type,
|
||||
addr: SocketAddrV4::new(ip, port as u16),
|
||||
token,
|
||||
privileged,
|
||||
}
|
||||
}
|
||||
code::GET_PEER_ADDRESS => {
|
||||
let username = r.string()?;
|
||||
let ip = r.ipv4()?;
|
||||
let port = r.u32()?;
|
||||
// Trailing obfuscation fields ignored.
|
||||
ServerEvent::UserAddress {
|
||||
username,
|
||||
addr: SocketAddrV4::new(ip, port as u16),
|
||||
}
|
||||
}
|
||||
code::CANNOT_CONNECT => {
|
||||
let token = r.u32()?;
|
||||
let username = r.string().ok();
|
||||
ServerEvent::CannotConnect { token, username }
|
||||
}
|
||||
code::NET_INFO => {
|
||||
let count = r.u32()?.min(1000);
|
||||
let mut parents = Vec::with_capacity(count as usize);
|
||||
for _ in 0..count {
|
||||
let username = r.string()?;
|
||||
let ip = r.ipv4()?;
|
||||
let port = r.u32()?;
|
||||
parents.push((username, SocketAddrV4::new(ip, port as u16)));
|
||||
}
|
||||
ServerEvent::NetInfo(parents)
|
||||
}
|
||||
code::FILE_SEARCH => {
|
||||
// Server-relayed search (same code we send): username, token, query.
|
||||
let username = r.string()?;
|
||||
let token = r.u32()?;
|
||||
let query = r.string()?;
|
||||
ServerEvent::ServerSearch {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
}
|
||||
}
|
||||
code::EMBEDDED_MESSAGE => ServerEvent::Embedded(r.rest()),
|
||||
code::EXCLUDED_SEARCH_PHRASES => {
|
||||
let count = r.u32()?.min(10_000);
|
||||
let mut phrases = Vec::with_capacity(count as usize);
|
||||
for _ in 0..count {
|
||||
phrases.push(r.string()?);
|
||||
}
|
||||
ServerEvent::ExcludedSearchPhrases(phrases)
|
||||
}
|
||||
other => ServerEvent::Unhandled { code: other },
|
||||
})
|
||||
}
|
||||
|
||||
fn hex(digest: impl AsRef<[u8]>) -> String {
|
||||
digest.as_ref().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn login_message_layout() {
|
||||
let msg = login("user", "pass");
|
||||
let mut r = Reader::new(Bytes::copy_from_slice(&msg[8..]));
|
||||
assert_eq!(&msg[4..8], &1u32.to_le_bytes());
|
||||
assert_eq!(r.string().unwrap(), "user");
|
||||
assert_eq!(r.string().unwrap(), "pass");
|
||||
assert_eq!(r.u32().unwrap(), VERSION);
|
||||
// md5("userpass")
|
||||
assert_eq!(r.string().unwrap(), "63e780c3f321d13109c71bf81805476e");
|
||||
assert_eq!(r.u32().unwrap(), MINOR_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_connect_to_peer() {
|
||||
let payload = Writer::new()
|
||||
.string("bob")
|
||||
.string("P")
|
||||
.u8(4)
|
||||
.u8(3)
|
||||
.u8(2)
|
||||
.u8(1) // 1.2.3.4 reversed on wire
|
||||
.u32(2234)
|
||||
.u32(99)
|
||||
.bool(false)
|
||||
.finish_u32(18);
|
||||
let ev = decode(18, Bytes::copy_from_slice(&payload[8..])).unwrap();
|
||||
match ev {
|
||||
ServerEvent::ConnectToPeer {
|
||||
username,
|
||||
conn_type,
|
||||
addr,
|
||||
token,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(username, "bob");
|
||||
assert_eq!(conn_type, "P");
|
||||
assert_eq!(addr, "1.2.3.4:2234".parse().unwrap());
|
||||
assert_eq!(token, 99);
|
||||
}
|
||||
other => panic!("wrong event: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Shared domain types used across peer and server messages.
|
||||
|
||||
use crate::wire::{Reader, Writer};
|
||||
use crate::Result;
|
||||
|
||||
/// File attribute types (Soulseek.NET `FileAttributeType`).
|
||||
pub const ATTR_BITRATE: u32 = 0;
|
||||
pub const ATTR_DURATION: u32 = 1;
|
||||
pub const ATTR_VBR: u32 = 2;
|
||||
pub const ATTR_SAMPLE_RATE: u32 = 4;
|
||||
pub const ATTR_BIT_DEPTH: u32 = 5;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct File {
|
||||
pub filename: String,
|
||||
pub size: u64,
|
||||
pub extension: String,
|
||||
pub attributes: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl File {
|
||||
pub(crate) fn read(r: &mut Reader) -> Result<File> {
|
||||
let _code = r.u8()?;
|
||||
let filename = r.string()?;
|
||||
let mut size = r.u64()?;
|
||||
// Some clients send a 32-bit size sign-extended to 64 bits.
|
||||
if size > i64::MAX as u64 && (size >> 32) == 0xFFFF_FFFF {
|
||||
size &= 0xFFFF_FFFF;
|
||||
}
|
||||
let extension = r.string()?;
|
||||
let attr_count = r.u32()?.min(64);
|
||||
let mut attributes = Vec::with_capacity(attr_count as usize);
|
||||
for _ in 0..attr_count {
|
||||
attributes.push((r.u32()?, r.u32()?));
|
||||
}
|
||||
Ok(File {
|
||||
filename,
|
||||
size,
|
||||
extension,
|
||||
attributes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write this file into a search/browse response body. `code` is the
|
||||
/// leading byte (1 for normal files).
|
||||
pub fn write(&self, mut w: Writer) -> Writer {
|
||||
w = w
|
||||
.u8(1)
|
||||
.string(&self.filename)
|
||||
.u64(self.size)
|
||||
.string(&self.extension);
|
||||
w = w.u32(self.attributes.len() as u32);
|
||||
for (ty, val) in &self.attributes {
|
||||
w = w.u32(*ty).u32(*val);
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
fn attr(&self, ty: u32) -> Option<u32> {
|
||||
self.attributes
|
||||
.iter()
|
||||
.find(|(t, _)| *t == ty)
|
||||
.map(|(_, v)| *v)
|
||||
}
|
||||
|
||||
pub fn bitrate(&self) -> Option<u32> {
|
||||
self.attr(ATTR_BITRATE)
|
||||
}
|
||||
|
||||
pub fn duration_secs(&self) -> Option<u32> {
|
||||
self.attr(ATTR_DURATION)
|
||||
}
|
||||
|
||||
pub fn is_vbr(&self) -> bool {
|
||||
self.attr(ATTR_VBR) == Some(1)
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> Option<u32> {
|
||||
self.attr(ATTR_SAMPLE_RATE)
|
||||
}
|
||||
|
||||
pub fn bit_depth(&self) -> Option<u32> {
|
||||
self.attr(ATTR_BIT_DEPTH)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchResponse {
|
||||
pub username: String,
|
||||
pub token: u32,
|
||||
pub files: Vec<File>,
|
||||
pub has_free_upload_slot: bool,
|
||||
pub upload_speed: u32,
|
||||
pub queue_length: u32,
|
||||
pub locked_files: Vec<File>,
|
||||
}
|
||||
|
||||
/// A shared directory in a browse response: backslash-separated name and
|
||||
/// its files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Directory {
|
||||
pub name: String,
|
||||
pub files: Vec<File>,
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
pub fn write(&self, mut w: Writer) -> Writer {
|
||||
w = w.string(&self.name).u32(self.files.len() as u32);
|
||||
for f in &self.files {
|
||||
w = f.write(w);
|
||||
}
|
||||
w
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Low-level message building and reading, mirroring Soulseek.NET's
|
||||
//! `MessageBuilder` / `MessageReader`.
|
||||
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
use flate2::read::ZlibDecoder;
|
||||
use std::io::Read;
|
||||
|
||||
use crate::{ProtoError, Result};
|
||||
|
||||
/// Builds one outgoing message. `finish_*` prepends the length header.
|
||||
pub struct Writer {
|
||||
buf: BytesMut,
|
||||
}
|
||||
|
||||
impl Default for Writer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
pub fn new() -> Self {
|
||||
Writer {
|
||||
buf: BytesMut::with_capacity(64),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn u8(mut self, v: u8) -> Self {
|
||||
self.buf.put_u8(v);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn u32(mut self, v: u32) -> Self {
|
||||
self.buf.put_u32_le(v);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn u64(mut self, v: u64) -> Self {
|
||||
self.buf.put_u64_le(v);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bool(self, v: bool) -> Self {
|
||||
self.u8(v as u8)
|
||||
}
|
||||
|
||||
pub fn string(mut self, s: &str) -> Self {
|
||||
self.buf.put_u32_le(s.len() as u32);
|
||||
self.buf.put_slice(s.as_bytes());
|
||||
self
|
||||
}
|
||||
|
||||
/// Frame with a u32 code: `[len][code:u32][payload]`.
|
||||
pub fn finish_u32(self, code: u32) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.buf.len() + 8);
|
||||
out.extend_from_slice(&((self.buf.len() as u32 + 4).to_le_bytes()));
|
||||
out.extend_from_slice(&code.to_le_bytes());
|
||||
out.extend_from_slice(&self.buf);
|
||||
out
|
||||
}
|
||||
|
||||
/// Frame with a u32 code and a zlib-compressed payload:
|
||||
/// `[len][code:u32][zlib(payload)]` (search responses, browse responses).
|
||||
pub fn finish_u32_compressed(self, code: u32) -> Vec<u8> {
|
||||
use flate2::write::ZlibEncoder;
|
||||
use flate2::Compression;
|
||||
use std::io::Write;
|
||||
|
||||
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||
enc.write_all(&self.buf).expect("zlib write");
|
||||
let compressed = enc.finish().expect("zlib finish");
|
||||
|
||||
let mut out = Vec::with_capacity(compressed.len() + 8);
|
||||
out.extend_from_slice(&((compressed.len() as u32 + 4).to_le_bytes()));
|
||||
out.extend_from_slice(&code.to_le_bytes());
|
||||
out.extend_from_slice(&compressed);
|
||||
out
|
||||
}
|
||||
|
||||
/// Frame with a single-byte code: `[len][code:u8][payload]`
|
||||
/// (PeerInit / PierceFirewall only).
|
||||
pub fn finish_u8(self, code: u8) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.buf.len() + 5);
|
||||
out.extend_from_slice(&((self.buf.len() as u32 + 1).to_le_bytes()));
|
||||
out.push(code);
|
||||
out.extend_from_slice(&self.buf);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one message payload (the bytes after the code).
|
||||
pub struct Reader {
|
||||
buf: Bytes,
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
pub fn new(payload: Bytes) -> Self {
|
||||
Reader { buf: payload }
|
||||
}
|
||||
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.buf.remaining()
|
||||
}
|
||||
|
||||
pub fn has_more(&self) -> bool {
|
||||
self.buf.has_remaining()
|
||||
}
|
||||
|
||||
/// Take all remaining bytes.
|
||||
pub fn rest(&mut self) -> Bytes {
|
||||
let n = self.buf.remaining();
|
||||
self.buf.copy_to_bytes(n)
|
||||
}
|
||||
|
||||
pub fn u8(&mut self) -> Result<u8> {
|
||||
if self.buf.remaining() < 1 {
|
||||
return Err(ProtoError::UnexpectedEof);
|
||||
}
|
||||
Ok(self.buf.get_u8())
|
||||
}
|
||||
|
||||
pub fn bool(&mut self) -> Result<bool> {
|
||||
Ok(self.u8()? != 0)
|
||||
}
|
||||
|
||||
pub fn u32(&mut self) -> Result<u32> {
|
||||
if self.buf.remaining() < 4 {
|
||||
return Err(ProtoError::UnexpectedEof);
|
||||
}
|
||||
Ok(self.buf.get_u32_le())
|
||||
}
|
||||
|
||||
pub fn u64(&mut self) -> Result<u64> {
|
||||
if self.buf.remaining() < 8 {
|
||||
return Err(ProtoError::UnexpectedEof);
|
||||
}
|
||||
Ok(self.buf.get_u64_le())
|
||||
}
|
||||
|
||||
/// IPv4 address; wire order is reversed relative to display order.
|
||||
pub fn ipv4(&mut self) -> Result<std::net::Ipv4Addr> {
|
||||
if self.buf.remaining() < 4 {
|
||||
return Err(ProtoError::UnexpectedEof);
|
||||
}
|
||||
let mut b = [0u8; 4];
|
||||
self.buf.copy_to_slice(&mut b);
|
||||
Ok(std::net::Ipv4Addr::new(b[3], b[2], b[1], b[0]))
|
||||
}
|
||||
|
||||
/// UTF-8 with Latin-1 fallback, matching what real-world peers send.
|
||||
pub fn string(&mut self) -> Result<String> {
|
||||
let len = self.u32()? as usize;
|
||||
if self.buf.remaining() < len {
|
||||
return Err(ProtoError::UnexpectedEof);
|
||||
}
|
||||
let bytes = self.buf.copy_to_bytes(len);
|
||||
Ok(match std::str::from_utf8(&bytes) {
|
||||
Ok(s) => s.to_owned(),
|
||||
Err(_) => bytes.iter().map(|&b| b as char).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the remaining payload with its zlib-decompressed form.
|
||||
pub fn decompress(&mut self) -> Result<()> {
|
||||
let mut out = Vec::new();
|
||||
ZlibDecoder::new(&self.buf[..]).read_to_end(&mut out)?;
|
||||
if out.len() > crate::MAX_MESSAGE_SIZE {
|
||||
return Err(ProtoError::TooLarge(out.len()));
|
||||
}
|
||||
self.buf = Bytes::from(out);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_primitives() {
|
||||
let msg = Writer::new().u32(7).string("héllo").u64(9).finish_u32(26);
|
||||
// len = 4 (code) + 4 + 4+6 + 8 = 26
|
||||
assert_eq!(&msg[0..4], &26u32.to_le_bytes());
|
||||
assert_eq!(&msg[4..8], &26u32.to_le_bytes());
|
||||
|
||||
let mut r = Reader::new(Bytes::copy_from_slice(&msg[8..]));
|
||||
assert_eq!(r.u32().unwrap(), 7);
|
||||
assert_eq!(r.string().unwrap(), "héllo");
|
||||
assert_eq!(r.u64().unwrap(), 9);
|
||||
assert!(!r.has_more());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u8_code_frame() {
|
||||
let msg = Writer::new().u32(123).finish_u8(0);
|
||||
assert_eq!(&msg[0..4], &5u32.to_le_bytes());
|
||||
assert_eq!(msg[4], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin1_fallback() {
|
||||
let mut raw = Vec::new();
|
||||
raw.extend_from_slice(&2u32.to_le_bytes());
|
||||
raw.extend_from_slice(&[0xE9, 0x21]); // "é!" in Latin-1, invalid UTF-8
|
||||
let mut r = Reader::new(Bytes::from(raw));
|
||||
assert_eq!(r.string().unwrap(), "é!");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user