1277 lines
45 KiB
Rust
Executable File
1277 lines
45 KiB
Rust
Executable File
//! BitChat protocol backend — P2P chat via libp2p Gossipsub.
|
|
//!
|
|
//! Implements a fully functional P2P chat layer over libp2p 0.54:
|
|
//!
|
|
//! - **F-5.1** Full libp2p Swarm: TCP transport, Noise encryption, Yamux
|
|
//! multiplexing, Gossipsub pub/sub, mDNS local discovery, Identify remote
|
|
//! discovery, Ping keepalive.
|
|
//! - **F-5.2** Bootstrap node dialing from `ServerEntry.extra.bootstrap`.
|
|
//! - **F-5.3** Gossipsub publish/subscribe for public chat, DMs, and file
|
|
//! offer advertisements.
|
|
//! - **F-5.4** Peer discovery and tracking from mDNS, Identify, and Gossipsub
|
|
//! `PeerAnnounce` messages. `/bitchat peers` displays the live peer table.
|
|
//! - **F-5.5** P2P file send via libp2p request-response protocol. File offers
|
|
//! are also advertised over Gossipsub so all peers can see them.
|
|
//!
|
|
//! ## Config (in `~/.nirc/config.toml`)
|
|
//!
|
|
//! ```toml
|
|
//! [[servers]]
|
|
//! name = "bitchat"
|
|
//! protocol = "bitchat"
|
|
//! address = "/ip4/0.0.0.0/tcp/9394"
|
|
//!
|
|
//! [servers.extra]
|
|
//! bootstrap = "/ip4/1.2.3.4/tcp/9394/p2p/QmSomePeerId"
|
|
//! ```
|
|
|
|
use crate::core::message::ChatMessage;
|
|
use crate::core::protocol::ProtocolType;
|
|
use futures::AsyncReadExt as _;
|
|
use futures::AsyncWriteExt as _;
|
|
use futures::StreamExt as _;
|
|
use libp2p::{
|
|
core::upgrade::Version,
|
|
gossipsub, identify, mdns, noise, ping,
|
|
request_response::{self, Codec, ResponseChannel},
|
|
swarm::{NetworkBehaviour, SwarmEvent},
|
|
tcp::tokio::Transport as TcpTransport,
|
|
yamux, Multiaddr, PeerId, Transport,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest as _, Sha256};
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
use tokio::sync::mpsc;
|
|
use tracing::{debug, info, warn};
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────────
|
|
|
|
/// Gossipsub topic for public chat messages.
|
|
pub const CHAT_TOPIC: &str = "nirc-bitchat-chat";
|
|
|
|
/// Gossipsub topic for file offer advertisements.
|
|
const FILE_TOPIC: &str = "nirc-bitchat-files";
|
|
|
|
/// Protocol name for the libp2p request-response file exchange.
|
|
const FILE_PROTOCOL: &str = "/nirc/file-exchange/1.0.0";
|
|
|
|
/// Maximum single request/response frame size (16 MiB — enough for moderate files).
|
|
const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
|
|
|
|
/// Default listen port when only a bare address or port is configured.
|
|
const DEFAULT_LISTEN_PORT: u16 = 9394;
|
|
|
|
/// Agent version string sent via the Identify protocol.
|
|
const AGENT_VERSION: &str = "nirc-rs/0.5.0";
|
|
|
|
/// Protocol version string sent via the Identify protocol.
|
|
const PROTOCOL_VERSION: &str = "nirc-bitchat/0.5.0";
|
|
|
|
/// How often we re-broadcast a `PeerAnnounce` so new peers learn our nickname.
|
|
const ANNOUNCE_INTERVAL_SECS: u64 = 300;
|
|
|
|
// ── Public message types ─────────────────────────────────────────────────
|
|
|
|
/// Wire-format messages exchanged over Gossipsub topics.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum BitChatMessage {
|
|
/// Public chat broadcast.
|
|
Chat {
|
|
sender: String,
|
|
body: String,
|
|
timestamp: i64,
|
|
},
|
|
/// Direct message (uses a derived per-pair topic; not cryptographically
|
|
/// private — true private DMs would need a direct protocol).
|
|
Direct {
|
|
sender: String,
|
|
target: String,
|
|
body: String,
|
|
timestamp: i64,
|
|
},
|
|
/// File offer advertisement (published to `FILE_TOPIC`).
|
|
FileOffer {
|
|
sender: String,
|
|
filename: String,
|
|
size: u64,
|
|
hash: String,
|
|
},
|
|
/// Peer nickname / version announcement (published to `CHAT_TOPIC`).
|
|
PeerAnnounce {
|
|
nickname: String,
|
|
version: String,
|
|
},
|
|
}
|
|
|
|
/// Configuration for the BitChat P2P layer.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BitChatConfig {
|
|
/// Multiaddr to listen on (e.g. `/ip4/0.0.0.0/tcp/9394`).
|
|
pub listen_addr: String,
|
|
/// Display nickname for Gossipsub messages.
|
|
pub nickname: String,
|
|
/// Optional bootstrap node multiaddr (dialed at startup).
|
|
pub bootstrap: Option<String>,
|
|
/// Channel back to the TUI for displaying messages / notices.
|
|
pub tx: mpsc::Sender<ChatMessage>,
|
|
}
|
|
|
|
/// Commands dispatched from the main event loop to the BitChat thread.
|
|
#[derive(Debug)]
|
|
pub enum BitChatCommand {
|
|
/// Send a public chat message.
|
|
Chat { body: String },
|
|
/// Send a direct message to a specific peer.
|
|
Direct { peer_id: String, body: String },
|
|
/// Initiate a P2P file transfer.
|
|
SendFile { peer_id: String, path: String },
|
|
/// Display the current peer table.
|
|
ListPeers,
|
|
/// Shut down the BitChat swarm.
|
|
Quit,
|
|
}
|
|
|
|
// ── File exchange protocol (F-5.5) ───────────────────────────────────────
|
|
|
|
/// Protocol name tag for the request-response file exchange.
|
|
#[derive(Clone)]
|
|
pub struct FileExchangeProtocol;
|
|
|
|
impl AsRef<str> for FileExchangeProtocol {
|
|
fn as_ref(&self) -> &str {
|
|
FILE_PROTOCOL
|
|
}
|
|
}
|
|
|
|
/// Request variants for the file exchange protocol.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum FileExchangeRequest {
|
|
/// Initiator advertises a file to the receiver.
|
|
Offer {
|
|
filename: String,
|
|
size: u64,
|
|
hash: String,
|
|
},
|
|
/// Receiver accepts the offer.
|
|
Accept,
|
|
/// Receiver requests a byte range.
|
|
ChunkRequest {
|
|
offset: u64,
|
|
length: usize,
|
|
},
|
|
/// Receiver confirms complete receipt.
|
|
Complete { hash: String },
|
|
/// Cancel an in-progress transfer.
|
|
Cancel,
|
|
}
|
|
|
|
/// Response variants for the file exchange protocol.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum FileExchangeResponse {
|
|
Accepted,
|
|
Rejected { reason: String },
|
|
/// File data chunk.
|
|
Chunk { offset: u64, data: Vec<u8> },
|
|
Ok,
|
|
Error { message: String },
|
|
}
|
|
|
|
/// Length-prefixed JSON codec for the file exchange request-response protocol.
|
|
///
|
|
/// Wire format: `[u32_le length][JSON payload]`.
|
|
#[derive(Clone, Default)]
|
|
pub struct FileExchangeCodec;
|
|
|
|
#[async_trait::async_trait]
|
|
impl Codec for FileExchangeCodec {
|
|
type Protocol = FileExchangeProtocol;
|
|
type Request = FileExchangeRequest;
|
|
type Response = FileExchangeResponse;
|
|
|
|
async fn read_request<R>(
|
|
&mut self,
|
|
_protocol: &Self::Protocol,
|
|
r: &mut R,
|
|
) -> std::io::Result<Self::Request>
|
|
where
|
|
R: futures::AsyncRead + Unpin + Send,
|
|
{
|
|
let mut len_buf = [0u8; 4];
|
|
r.read_exact(&mut len_buf).await?;
|
|
let len = u32::from_le_bytes(len_buf) as usize;
|
|
let mut buf = vec![0u8; len];
|
|
r.read_exact(&mut buf).await?;
|
|
serde_json::from_slice(&buf)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
|
}
|
|
|
|
async fn read_response<R>(
|
|
&mut self,
|
|
_protocol: &Self::Protocol,
|
|
r: &mut R,
|
|
) -> std::io::Result<Self::Response>
|
|
where
|
|
R: futures::AsyncRead + Unpin + Send,
|
|
{
|
|
let mut len_buf = [0u8; 4];
|
|
r.read_exact(&mut len_buf).await?;
|
|
let len = u32::from_le_bytes(len_buf) as usize;
|
|
let mut buf = vec![0u8; len];
|
|
r.read_exact(&mut buf).await?;
|
|
serde_json::from_slice(&buf)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
|
}
|
|
|
|
async fn write_request<W>(
|
|
&mut self,
|
|
_protocol: &Self::Protocol,
|
|
w: &mut W,
|
|
req: Self::Request,
|
|
) -> std::io::Result<()>
|
|
where
|
|
W: futures::AsyncWrite + Unpin + Send,
|
|
{
|
|
let data = serde_json::to_vec(&req)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
w.write_all(&(data.len() as u32).to_le_bytes()).await?;
|
|
w.write_all(&data).await?;
|
|
w.flush().await
|
|
}
|
|
|
|
async fn write_response<W>(
|
|
&mut self,
|
|
_protocol: &Self::Protocol,
|
|
w: &mut W,
|
|
resp: Self::Response,
|
|
) -> std::io::Result<()>
|
|
where
|
|
W: futures::AsyncWrite + Unpin + Send,
|
|
{
|
|
let data = serde_json::to_vec(&resp)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
w.write_all(&(data.len() as u32).to_le_bytes()).await?;
|
|
w.write_all(&data).await?;
|
|
w.flush().await
|
|
}
|
|
}
|
|
|
|
// ── Peer tracking (F-5.4) ───────────────────────────────────────────────
|
|
|
|
/// Information tracked about a discovered peer.
|
|
#[derive(Debug, Clone)]
|
|
struct PeerInfo {
|
|
peer_id: PeerId,
|
|
nickname: Option<String>,
|
|
addresses: Vec<Multiaddr>,
|
|
agent_version: Option<String>,
|
|
}
|
|
|
|
// ── Combined network behaviour ───────────────────────────────────────────
|
|
|
|
/// Composes all libp2p behaviours into a single `NetworkBehaviour` that the
|
|
/// `Swarm` drives. The `#[derive(NetworkBehaviour)]` macro generates a
|
|
/// `BitChatEvent` enum with one variant per field.
|
|
#[derive(NetworkBehaviour)]
|
|
struct BitChatBehaviour {
|
|
gossipsub: gossipsub::Behaviour,
|
|
mdns: mdns::tokio::Behaviour,
|
|
identify: identify::Behaviour,
|
|
ping: ping::Behaviour,
|
|
file_exchange: request_response::Behaviour<FileExchangeCodec>,
|
|
}
|
|
|
|
/// Type alias for the `BitChatBehaviour`-derived event enum so call sites
|
|
/// can refer to it as `BitChatEvent` (the libp2p macro auto-generates an
|
|
/// enum named `<StructName>Event` — i.e. `BitChatBehaviourEvent`).
|
|
type BitChatEvent = BitChatBehaviourEvent;
|
|
|
|
// ── Main runtime ─────────────────────────────────────────────────────────
|
|
|
|
/// Entry point for the BitChat P2P event loop.
|
|
///
|
|
/// Builds a full libp2p swarm, subscribes to Gossipsub topics, dials the
|
|
/// bootstrap node (if configured), and enters a `tokio::select!` loop that
|
|
/// multiplexes swarm events, incoming commands, and periodic peer announces.
|
|
pub async fn run_bitchat(
|
|
config: BitChatConfig,
|
|
mut cmd_rx: mpsc::Receiver<BitChatCommand>,
|
|
) -> anyhow::Result<()> {
|
|
info!(nickname = %config.nickname, "BitChat starting");
|
|
|
|
// ── Identity ─────────────────────────────────────────────────────────
|
|
let local_key = libp2p::identity::Keypair::generate_ed25519();
|
|
let local_peer_id = local_key.public().to_peer_id();
|
|
info!(%local_peer_id, "BitChat peer ID");
|
|
|
|
// ── F-5.1: Transport (TCP + Noise + Yamux) ───────────────────────────
|
|
let transport = TcpTransport::new(Default::default())
|
|
.upgrade(Version::V1Lazy)
|
|
.authenticate(noise::Config::new(&local_key)?)
|
|
.multiplex(yamux::Config::default())
|
|
.boxed();
|
|
|
|
// ── F-5.3: Gossipsub ─────────────────────────────────────────────────
|
|
let gossipsub = {
|
|
let gs_config = gossipsub::Config::default();
|
|
let mut gs = gossipsub::Behaviour::new(
|
|
gossipsub::MessageAuthenticity::Signed(local_key.clone()),
|
|
gs_config,
|
|
).map_err(|e| anyhow::anyhow!("gossipsub: {e}"))?;
|
|
let _ = gs.subscribe(&gossipsub::Sha256Topic::new(CHAT_TOPIC));
|
|
let _ = gs.subscribe(&gossipsub::Sha256Topic::new(FILE_TOPIC));
|
|
gs
|
|
};
|
|
|
|
// ── F-5.4: mDNS (local peer discovery) ───────────────────────────────
|
|
let mdns = mdns::tokio::Behaviour::new(mdns::Config::default(), local_peer_id)
|
|
.map_err(|e| anyhow::anyhow!("mdns: {e}"))?;
|
|
|
|
// ── F-5.4: Identify (remote peer discovery) ──────────────────────────
|
|
let identify = identify::Behaviour::new(identify::Config::new(
|
|
PROTOCOL_VERSION.to_string(),
|
|
local_key.public(),
|
|
));
|
|
|
|
// ── Ping (keepalive) ──────────────────────────────────────────────────
|
|
let ping = ping::Behaviour::new(ping::Config::new());
|
|
|
|
// ── F-5.5: Request-response file exchange ────────────────────────────
|
|
let file_exchange = request_response::Behaviour::new(
|
|
[(FileExchangeProtocol, request_response::ProtocolSupport::Full)],
|
|
request_response::Config::default(),
|
|
);
|
|
|
|
// ── F-5.1: Build the Swarm ───────────────────────────────────────────
|
|
let behaviour = BitChatBehaviour {
|
|
gossipsub,
|
|
mdns,
|
|
identify,
|
|
ping,
|
|
file_exchange,
|
|
};
|
|
|
|
let mut swarm = libp2p::Swarm::new(
|
|
transport,
|
|
behaviour,
|
|
local_peer_id,
|
|
libp2p::swarm::Config::with_tokio_executor(),
|
|
);
|
|
|
|
// ── F-5.1: Listen ────────────────────────────────────────────────────
|
|
let listen_multiaddr = parse_listen_addr(&config.listen_addr);
|
|
swarm.listen_on(listen_multiaddr.clone())?;
|
|
info!(%listen_multiaddr, "BitChat listening");
|
|
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
CHAT_TOPIC,
|
|
&format!("P2P peer {local_peer_id} listening on {listen_multiaddr}"),
|
|
))
|
|
.await;
|
|
|
|
// ── F-5.2: Dial bootstrap node ───────────────────────────────────────
|
|
if let Some(ref bootstrap) = config.bootstrap {
|
|
match bootstrap.parse::<Multiaddr>() {
|
|
Ok(addr) => {
|
|
info!(%addr, "Dialing bootstrap node");
|
|
if let Err(e) = swarm.dial(addr.clone()) {
|
|
warn!(%e, "Failed to dial bootstrap node");
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::error(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("Failed to dial bootstrap {addr}: {e}"),
|
|
))
|
|
.await;
|
|
} else {
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("Dialing bootstrap {addr}..."),
|
|
))
|
|
.await;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!(%bootstrap, %e, "Invalid bootstrap multiaddr");
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::error(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("Invalid bootstrap address '{bootstrap}': {e}"),
|
|
))
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── F-5.4: Peer table ────────────────────────────────────────────────
|
|
let mut known_peers: HashMap<PeerId, PeerInfo> = HashMap::new();
|
|
|
|
// ── Periodic announce timer ──────────────────────────────────────────
|
|
let mut announce_timer = tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
|
|
|
|
// Send initial PeerAnnounce so peers on the topic learn our nickname.
|
|
publish_peer_announce(&mut swarm, &config.nickname);
|
|
|
|
// ── Event loop ───────────────────────────────────────────────────────
|
|
loop {
|
|
tokio::select! {
|
|
event = swarm.select_next_some() => {
|
|
handle_swarm_event(
|
|
event,
|
|
&mut swarm,
|
|
&config,
|
|
&mut known_peers,
|
|
&local_peer_id,
|
|
)
|
|
.await;
|
|
}
|
|
cmd = cmd_rx.recv() => {
|
|
match cmd {
|
|
Some(BitChatCommand::Chat { body }) => {
|
|
publish_chat(&mut swarm, &config, &body);
|
|
}
|
|
Some(BitChatCommand::Direct { peer_id, body }) => {
|
|
publish_dm(&mut swarm, &config, &peer_id, &body).await;
|
|
}
|
|
Some(BitChatCommand::SendFile { peer_id, path }) => {
|
|
send_file_offer(&mut swarm, &config, &peer_id, &path).await;
|
|
}
|
|
Some(BitChatCommand::ListPeers) => {
|
|
show_peers(&config.tx, &known_peers, &local_peer_id).await;
|
|
}
|
|
Some(BitChatCommand::Quit) | None => {
|
|
info!("BitChat stopping");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
_ = announce_timer.tick() => {
|
|
publish_peer_announce(&mut swarm, &config.nickname);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ── Transport helpers ────────────────────────────────────────────────────
|
|
|
|
/// Parse a user-supplied listen address into a libp2p `Multiaddr`.
|
|
///
|
|
/// Accepts full multiaddr strings (`/ip4/0.0.0.0/tcp/9394`), bare port
|
|
/// numbers (`9394`), or `host:port` pairs.
|
|
fn parse_listen_addr(addr: &str) -> Multiaddr {
|
|
let default: Multiaddr = format!("/ip4/0.0.0.0/tcp/{DEFAULT_LISTEN_PORT}")
|
|
.parse()
|
|
.expect("hardcoded default multiaddr must be valid");
|
|
if addr.contains('/') {
|
|
// Already a multiaddr — use as-is (or defaults to default on parse error).
|
|
addr.parse().unwrap_or(default)
|
|
} else if let Ok(port) = addr.parse::<u16>() {
|
|
format!("/ip4/0.0.0.0/tcp/{port}").parse().unwrap_or(default)
|
|
} else if let Some((host, port_str)) = addr.split_once(':') {
|
|
let port = port_str.parse::<u16>().unwrap_or(DEFAULT_LISTEN_PORT);
|
|
format!("/ip4/{host}/tcp/{port}").parse().unwrap_or(default)
|
|
} else {
|
|
default
|
|
}
|
|
}
|
|
|
|
// ── Gossipsub helpers (F-5.3) ───────────────────────────────────────────
|
|
|
|
/// Publish a public chat message to the `CHAT_TOPIC`.
|
|
fn publish_chat(swarm: &mut libp2p::Swarm<BitChatBehaviour>, config: &BitChatConfig, body: &str) {
|
|
let msg = BitChatMessage::Chat {
|
|
sender: config.nickname.clone(),
|
|
body: body.to_owned(),
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
};
|
|
match serde_json::to_vec(&msg) {
|
|
Ok(data) => {
|
|
if let Err(e) = swarm.behaviour_mut().gossipsub.publish(gossipsub::Sha256Topic::new(CHAT_TOPIC), data) {
|
|
warn!(%e, "Failed to publish chat message");
|
|
}
|
|
}
|
|
Err(e) => warn!(%e, "Failed to serialise chat message"),
|
|
}
|
|
}
|
|
|
|
/// Publish a direct message to a per-pair derived topic.
|
|
///
|
|
/// The topic is `nirc-bitchat-dm-<sorted peer ids>` so only the two
|
|
/// participants subscribe. This is **not** cryptographically private — any
|
|
/// peer who knows the topic can subscribe. True private DMs would need a
|
|
/// dedicated direct-messaging protocol (future enhancement).
|
|
async fn publish_dm(
|
|
swarm: &mut libp2p::Swarm<BitChatBehaviour>,
|
|
config: &BitChatConfig,
|
|
peer_id_str: &str,
|
|
body: &str,
|
|
) {
|
|
let peer_id = match peer_id_str.parse::<PeerId>() {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::error(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("Invalid peer ID '{peer_id_str}': {e}"),
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Derive a deterministic per-pair topic.
|
|
let local_str = swarm.local_peer_id().to_string();
|
|
let mut ids = [local_str, peer_id.to_string()];
|
|
ids.sort();
|
|
let dm_topic = format!("nirc-bitchat-dm-{}", ids.join("-"));
|
|
|
|
// Ensure we're subscribed to the DM topic.
|
|
let _ = swarm.behaviour_mut().gossipsub.subscribe(&gossipsub::Sha256Topic::new(dm_topic.clone()));
|
|
|
|
let msg = BitChatMessage::Direct {
|
|
sender: config.nickname.clone(),
|
|
target: peer_id.to_string(),
|
|
body: body.to_owned(),
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
};
|
|
match serde_json::to_vec(&msg) {
|
|
Ok(data) => {
|
|
if let Err(e) = swarm.behaviour_mut().gossipsub.publish(gossipsub::Sha256Topic::new(dm_topic), data) {
|
|
warn!(%e, "Failed to publish DM");
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::error(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("DM send failed: {e}"),
|
|
))
|
|
.await;
|
|
} else {
|
|
// Echo the message locally so the sender sees it.
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::private(
|
|
ProtocolType::BitChat,
|
|
&short_id(&peer_id),
|
|
&config.nickname,
|
|
body,
|
|
true,
|
|
))
|
|
.await;
|
|
}
|
|
}
|
|
Err(e) => warn!(%e, "Failed to serialise DM"),
|
|
}
|
|
}
|
|
|
|
/// Publish a `PeerAnnounce` so peers learn our nickname.
|
|
fn publish_peer_announce(swarm: &mut libp2p::Swarm<BitChatBehaviour>, nickname: &str) {
|
|
let msg = BitChatMessage::PeerAnnounce {
|
|
nickname: nickname.to_owned(),
|
|
version: AGENT_VERSION.to_owned(),
|
|
};
|
|
if let Ok(data) = serde_json::to_vec(&msg) {
|
|
if let Err(e) = swarm.behaviour_mut().gossipsub.publish(gossipsub::Sha256Topic::new(CHAT_TOPIC), data) {
|
|
warn!(%e, "Failed to publish peer announce");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── File transfer (F-5.5) ───────────────────────────────────────────────
|
|
|
|
/// Initiate a P2P file transfer to a connected peer.
|
|
///
|
|
/// Reads the file, computes its SHA-256 hash, and sends a `FileOffer`
|
|
/// request via the request-response protocol. Also advertises the offer
|
|
/// on the `FILE_TOPIC` Gossipsub topic.
|
|
async fn send_file_offer(
|
|
swarm: &mut libp2p::Swarm<BitChatBehaviour>,
|
|
config: &BitChatConfig,
|
|
peer_id_str: &str,
|
|
path: &str,
|
|
) {
|
|
let peer_id = match peer_id_str.parse::<PeerId>() {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::error(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("Invalid peer ID: {e}"),
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
// The peer must be connected for request-response to work.
|
|
if !swarm.is_connected(&peer_id) {
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::error(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!(
|
|
"Peer {peer_id} is not connected. Use /bitchat peers to check."
|
|
),
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
// Read the file into memory.
|
|
let file_data = match tokio::fs::read(path).await {
|
|
Ok(data) => data,
|
|
Err(e) => {
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::error(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("Cannot read '{path}': {e}"),
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let filename = std::path::Path::new(path)
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let size = file_data.len() as u64;
|
|
let hash = file_hash(&file_data);
|
|
|
|
info!(%peer_id, %filename, size, %hash, "Sending file offer");
|
|
|
|
// Send the offer via request-response.
|
|
let request = FileExchangeRequest::Offer {
|
|
filename: filename.clone(),
|
|
size,
|
|
hash: hash.clone(),
|
|
};
|
|
let _req_id = swarm
|
|
.behaviour_mut()
|
|
.file_exchange
|
|
.send_request(&peer_id, request);
|
|
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!(
|
|
"File offer sent to {peer_id}: {filename} ({size} bytes, sha256:{hash})"
|
|
),
|
|
))
|
|
.await;
|
|
|
|
// Also advertise on the Gossipsub FILE_TOPIC.
|
|
let gossip_msg = BitChatMessage::FileOffer {
|
|
sender: config.nickname.clone(),
|
|
filename: filename.clone(),
|
|
size,
|
|
hash: hash.clone(),
|
|
};
|
|
if let Ok(data) = serde_json::to_vec(&gossip_msg) {
|
|
let _ = swarm.behaviour_mut().gossipsub.publish(gossipsub::Sha256Topic::new(FILE_TOPIC), data);
|
|
}
|
|
}
|
|
|
|
// ── Peer list (F-5.4) ───────────────────────────────────────────────────
|
|
|
|
/// Display the live peer table as a TUI notice.
|
|
async fn show_peers(
|
|
tx: &mpsc::Sender<ChatMessage>,
|
|
known_peers: &HashMap<PeerId, PeerInfo>,
|
|
local_peer_id: &PeerId,
|
|
) {
|
|
if known_peers.is_empty() {
|
|
let _ = tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
CHAT_TOPIC,
|
|
"No peers discovered yet. Connect to a bootstrap node or wait for mDNS.",
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let mut lines = vec![format!(" {} (you)", local_peer_id)];
|
|
for (peer_id, info) in known_peers {
|
|
let nick = info.nickname.as_deref().unwrap_or("?");
|
|
let addr = info
|
|
.addresses
|
|
.first()
|
|
.map(|a| a.to_string())
|
|
.unwrap_or_else(|| "-".to_string());
|
|
lines.push(format!(" {peer_id} [{nick}] {addr}"));
|
|
}
|
|
|
|
let _ = tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
CHAT_TOPIC,
|
|
&lines.join("\n"),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
// ── Swarm event handler ──────────────────────────────────────────────────
|
|
|
|
/// Dispatch a `SwarmEvent` to the appropriate sub-handler.
|
|
async fn handle_swarm_event(
|
|
event: SwarmEvent<BitChatEvent>,
|
|
swarm: &mut libp2p::Swarm<BitChatBehaviour>,
|
|
config: &BitChatConfig,
|
|
known_peers: &mut HashMap<PeerId, PeerInfo>,
|
|
local_peer_id: &PeerId,
|
|
) {
|
|
match event {
|
|
// ── Connection lifecycle ─────────────────────────────────────────
|
|
SwarmEvent::NewListenAddr { address, .. } => {
|
|
info!(%address, "BitChat listening on");
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
CHAT_TOPIC,
|
|
&format!("Listening on {address}"),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
SwarmEvent::ConnectionEstablished { peer_id, .. } => {
|
|
info!(%peer_id, "Peer connected");
|
|
known_peers
|
|
.entry(peer_id)
|
|
.or_insert_with(|| PeerInfo {
|
|
peer_id,
|
|
nickname: None,
|
|
addresses: Vec::new(),
|
|
agent_version: None,
|
|
});
|
|
}
|
|
|
|
SwarmEvent::ConnectionClosed { peer_id, cause, .. } => {
|
|
debug!(%peer_id, ?cause, "Peer disconnected");
|
|
// Keep the entry — the peer may still be reachable via mDNS.
|
|
}
|
|
|
|
SwarmEvent::Dialing { peer_id, .. } => {
|
|
debug!(?peer_id, "Dialing peer");
|
|
}
|
|
|
|
SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => {
|
|
warn!(?peer_id, %error, "Outgoing connection error");
|
|
if let Some(peer) = peer_id {
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::error(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("Failed to connect to {peer}: {error}"),
|
|
))
|
|
.await;
|
|
}
|
|
}
|
|
|
|
// ── F-5.3: Gossipsub messages ────────────────────────────────────
|
|
SwarmEvent::Behaviour(BitChatEvent::Gossipsub(gossipsub::Event::Message {
|
|
message,
|
|
..
|
|
})) => {
|
|
// Ignore our own echoed messages.
|
|
if message.source.as_ref() == Some(local_peer_id) {
|
|
return;
|
|
}
|
|
|
|
let source_peer = message.source;
|
|
let source_str = source_peer
|
|
.as_ref()
|
|
.map(|p| short_id(p))
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
|
|
match serde_json::from_slice::<BitChatMessage>(&message.data) {
|
|
Ok(BitChatMessage::Chat { sender, body, timestamp }) => {
|
|
let ts = chrono::DateTime::from_timestamp_millis(timestamp);
|
|
let mut msg = ChatMessage::text(
|
|
ProtocolType::BitChat,
|
|
CHAT_TOPIC,
|
|
&sender,
|
|
&body,
|
|
false,
|
|
);
|
|
if let Some(t) = ts {
|
|
msg = msg.with_timestamp(t).with_remote_ts();
|
|
}
|
|
// Update nickname from incoming messages.
|
|
if let Some(peer) = source_peer {
|
|
if let Some(info) = known_peers.get_mut(&peer) {
|
|
if info.nickname.is_none() {
|
|
info.nickname = Some(sender.clone());
|
|
}
|
|
}
|
|
}
|
|
let _ = config.tx.send(msg).await;
|
|
}
|
|
|
|
Ok(BitChatMessage::Direct {
|
|
sender,
|
|
target,
|
|
body,
|
|
timestamp,
|
|
}) => {
|
|
// Only display DMs targeted at us.
|
|
if target != local_peer_id.to_string() {
|
|
return;
|
|
}
|
|
let ts = chrono::DateTime::from_timestamp_millis(timestamp);
|
|
let mut msg = ChatMessage::private(
|
|
ProtocolType::BitChat,
|
|
&source_str,
|
|
&sender,
|
|
&body,
|
|
false,
|
|
);
|
|
if let Some(t) = ts {
|
|
msg = msg.with_timestamp(t).with_remote_ts();
|
|
}
|
|
let _ = config.tx.send(msg).await;
|
|
}
|
|
|
|
Ok(BitChatMessage::PeerAnnounce { nickname, version }) => {
|
|
if let Some(peer) = source_peer {
|
|
if let Some(info) = known_peers.get_mut(&peer) {
|
|
info.nickname = Some(nickname.clone());
|
|
}
|
|
debug!(%peer, %nickname, %version, "Peer announced");
|
|
}
|
|
}
|
|
|
|
Ok(BitChatMessage::FileOffer {
|
|
sender,
|
|
filename,
|
|
size,
|
|
hash,
|
|
}) => {
|
|
let peer_display = source_peer
|
|
.as_ref()
|
|
.map(|p| p.to_string())
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
FILE_TOPIC,
|
|
&format!(
|
|
"File offer from {sender} [{peer_display}]: \
|
|
{filename} ({size} bytes, sha256:{hash})"
|
|
),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
Err(e) => {
|
|
debug!(%e, "Failed to deserialize Gossipsub message");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── F-5.4: mDNS discovery ────────────────────────────────────────
|
|
SwarmEvent::Behaviour(BitChatEvent::Mdns(mdns::Event::Discovered(list))) => {
|
|
for (peer_id, addr) in list {
|
|
debug!(%peer_id, %addr, "mDNS discovered peer");
|
|
let entry = known_peers
|
|
.entry(peer_id)
|
|
.or_insert_with(|| PeerInfo {
|
|
peer_id,
|
|
nickname: None,
|
|
addresses: Vec::new(),
|
|
agent_version: None,
|
|
});
|
|
if !entry.addresses.contains(&addr) {
|
|
entry.addresses.push(addr.clone());
|
|
}
|
|
// Auto-dial mDNS-discovered peers using the address we just got.
|
|
if !swarm.is_connected(&peer_id) {
|
|
let _ = swarm.dial(addr);
|
|
}
|
|
}
|
|
}
|
|
|
|
SwarmEvent::Behaviour(BitChatEvent::Mdns(mdns::Event::Expired(list))) => {
|
|
for (peer_id, _addr) in list {
|
|
debug!(%peer_id, "mDNS peer expired");
|
|
}
|
|
}
|
|
|
|
// ── F-5.4: Identify ─────────────────────────────────────────────
|
|
SwarmEvent::Behaviour(BitChatEvent::Identify(identify::Event::Received {
|
|
peer_id,
|
|
connection_id: _,
|
|
info,
|
|
})) => {
|
|
debug!(
|
|
%peer_id,
|
|
agent = %info.agent_version,
|
|
addrs = ?info.listen_addrs,
|
|
"Identify received"
|
|
);
|
|
if let Some(peer_info) = known_peers.get_mut(&peer_id) {
|
|
peer_info.agent_version = Some(info.agent_version.clone());
|
|
// Merge new addresses.
|
|
for addr in &info.listen_addrs {
|
|
if !peer_info.addresses.contains(addr) {
|
|
peer_info.addresses.push(addr.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Ping keepalive ───────────────────────────────────────────────
|
|
SwarmEvent::Behaviour(BitChatEvent::Ping(ping::Event {
|
|
peer,
|
|
result: Ok(duration),
|
|
..
|
|
})) => {
|
|
debug!(%peer, ?duration, "Ping OK");
|
|
}
|
|
SwarmEvent::Behaviour(BitChatEvent::Ping(ping::Event {
|
|
peer,
|
|
result: Err(e),
|
|
..
|
|
})) => {
|
|
warn!(%peer, %e, "Ping failed");
|
|
}
|
|
|
|
// ── F-5.5: Request-response (file exchange) ─────────────────────
|
|
SwarmEvent::Behaviour(BitChatEvent::FileExchange(event)) => {
|
|
handle_rr_event(event, swarm, config).await;
|
|
}
|
|
|
|
// ── Ignore other events ──────────────────────────────────────────
|
|
_ => {
|
|
debug!(?event, "Unhandled swarm event");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Request-response handler (F-5.5) ─────────────────────────────────────
|
|
|
|
/// Handle all request-response events for the file exchange protocol.
|
|
async fn handle_rr_event(
|
|
event: request_response::Event<FileExchangeRequest, FileExchangeResponse>,
|
|
swarm: &mut libp2p::Swarm<BitChatBehaviour>,
|
|
config: &BitChatConfig,
|
|
) {
|
|
match event {
|
|
request_response::Event::Message { peer, message } => match message {
|
|
request_response::Message::Request {
|
|
request, channel, ..
|
|
} => {
|
|
handle_incoming_request(request, channel, swarm, config, &peer).await;
|
|
}
|
|
request_response::Message::Response { response, .. } => {
|
|
handle_incoming_response(response, config, &peer).await;
|
|
}
|
|
},
|
|
|
|
request_response::Event::InboundFailure {
|
|
peer, error, ..
|
|
} => {
|
|
warn!(%peer, %error, "Inbound file-exchange failure");
|
|
}
|
|
|
|
request_response::Event::OutboundFailure {
|
|
peer, error, ..
|
|
} => {
|
|
warn!(%peer, %error, "Outbound file-exchange failure");
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::error(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("File transfer error with {peer}: {error}"),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
request_response::Event::ResponseSent { .. } => {
|
|
debug!("File-exchange response sent");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle an incoming file exchange request from a remote peer.
|
|
async fn handle_incoming_request(
|
|
request: FileExchangeRequest,
|
|
channel: ResponseChannel<FileExchangeResponse>,
|
|
swarm: &mut libp2p::Swarm<BitChatBehaviour>,
|
|
config: &BitChatConfig,
|
|
peer: &PeerId,
|
|
) {
|
|
match request {
|
|
FileExchangeRequest::Offer {
|
|
filename,
|
|
size,
|
|
hash,
|
|
} => {
|
|
info!(%peer, %filename, size, %hash, "File offer received");
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!(
|
|
"File offer from {peer}: {filename} ({size} bytes, sha256:{hash}) — auto-accepting"
|
|
),
|
|
))
|
|
.await;
|
|
|
|
// Auto-accept the offer.
|
|
let _ = swarm
|
|
.behaviour_mut()
|
|
.file_exchange
|
|
.send_response(channel, FileExchangeResponse::Accepted);
|
|
}
|
|
|
|
FileExchangeRequest::Accept => {
|
|
info!(%peer, "Peer accepted file offer");
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("{peer} accepted the file offer"),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
FileExchangeRequest::ChunkRequest { offset, length } => {
|
|
debug!(%peer, offset, length, "Chunk request received");
|
|
// Chunk-based transfer is a future enhancement. For the initial
|
|
// implementation, files are offered as a whole-unit handshake.
|
|
let _ = swarm.behaviour_mut().file_exchange.send_response(
|
|
channel,
|
|
FileExchangeResponse::Error {
|
|
message: "Chunk-based transfer not yet implemented. \
|
|
Use a single Offer for files < 16 MiB."
|
|
.to_string(),
|
|
},
|
|
);
|
|
}
|
|
|
|
FileExchangeRequest::Complete { hash } => {
|
|
info!(%peer, %hash, "File transfer complete (receiver confirmed)");
|
|
let _ = swarm
|
|
.behaviour_mut()
|
|
.file_exchange
|
|
.send_response(channel, FileExchangeResponse::Ok);
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("File transfer to {peer} complete (sha256:{hash})"),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
FileExchangeRequest::Cancel => {
|
|
info!(%peer, "File transfer cancelled by receiver");
|
|
let _ = swarm
|
|
.behaviour_mut()
|
|
.file_exchange
|
|
.send_response(channel, FileExchangeResponse::Ok);
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("File transfer with {peer} cancelled"),
|
|
))
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle an incoming file exchange response from a remote peer.
|
|
async fn handle_incoming_response(
|
|
response: FileExchangeResponse,
|
|
config: &BitChatConfig,
|
|
peer: &PeerId,
|
|
) {
|
|
match response {
|
|
FileExchangeResponse::Accepted => {
|
|
info!(%peer, "Peer accepted file offer");
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("{peer} accepted the file offer"),
|
|
))
|
|
.await;
|
|
}
|
|
FileExchangeResponse::Rejected { reason } => {
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::notice(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("{peer} rejected file offer: {reason}"),
|
|
))
|
|
.await;
|
|
}
|
|
FileExchangeResponse::Chunk { offset, data } => {
|
|
debug!(%peer, offset, len = data.len(), "Received file chunk");
|
|
// Future: write chunk to the target file at `offset`.
|
|
}
|
|
FileExchangeResponse::Ok => {
|
|
debug!(%peer, "File exchange OK");
|
|
}
|
|
FileExchangeResponse::Error { message } => {
|
|
let _ = config
|
|
.tx
|
|
.send(ChatMessage::error(
|
|
ProtocolType::BitChat,
|
|
"",
|
|
&format!("File transfer error from {peer}: {message}"),
|
|
))
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Utility functions ────────────────────────────────────────────────────
|
|
|
|
/// Compute the SHA-256 hash of a byte slice (hex-encoded).
|
|
fn file_hash(data: &[u8]) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(data);
|
|
to_hex(&hasher.finalize())
|
|
}
|
|
|
|
/// Convert bytes to a lowercase hex string (avoids adding the `hex` crate).
|
|
fn to_hex(bytes: &[u8]) -> String {
|
|
let mut s = String::with_capacity(bytes.len() * 2);
|
|
for b in bytes {
|
|
s.push_str(&format!("{b:02x}"));
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Truncate a `PeerId` for compact display in DM source fields.
|
|
fn short_id(peer: &PeerId) -> String {
|
|
let s = peer.to_string();
|
|
if s.len() > 12 {
|
|
s[..12].to_string()
|
|
} else {
|
|
s
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_listen_multiaddr() {
|
|
let addr = parse_listen_addr("/ip4/127.0.0.1/tcp/9394");
|
|
assert_eq!(addr.to_string(), "/ip4/127.0.0.1/tcp/9394");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_listen_port_only() {
|
|
let addr = parse_listen_addr("12345");
|
|
assert_eq!(addr.to_string(), "/ip4/0.0.0.0/tcp/12345");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_listen_host_port() {
|
|
let addr = parse_listen_addr("192.168.1.1:9394");
|
|
assert_eq!(addr.to_string(), "/ip4/192.168.1.1/tcp/9394");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_listen_fallback() {
|
|
let addr = parse_listen_addr("invalid");
|
|
assert_eq!(addr.to_string(), "/ip4/0.0.0.0/tcp/9394");
|
|
}
|
|
|
|
#[test]
|
|
fn file_hash_deterministic() {
|
|
let h1 = file_hash(b"hello world");
|
|
let h2 = file_hash(b"hello world");
|
|
let h3 = file_hash(b"different");
|
|
assert_eq!(h1, h2);
|
|
assert_ne!(h1, h3);
|
|
assert_eq!(h1.len(), 64); // SHA-256 = 32 bytes = 64 hex chars
|
|
}
|
|
|
|
#[test]
|
|
fn to_hex_output() {
|
|
assert_eq!(to_hex(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
|
|
assert_eq!(to_hex(&[]), "");
|
|
}
|
|
|
|
#[test]
|
|
fn short_id_truncates() {
|
|
let key = libp2p::identity::Keypair::generate_ed25519();
|
|
let pid = key.public().to_peer_id();
|
|
let s = short_id(&pid);
|
|
assert!(s.len() <= 12);
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_bitchat_message() {
|
|
let msg = BitChatMessage::Chat {
|
|
sender: "alice".to_string(),
|
|
body: "hello".to_string(),
|
|
timestamp: 1000,
|
|
};
|
|
let data = serde_json::to_vec(&msg).unwrap();
|
|
let decoded: BitChatMessage = serde_json::from_slice(&data).unwrap();
|
|
match decoded {
|
|
BitChatMessage::Chat { sender, body, .. } => {
|
|
assert_eq!(sender, "alice");
|
|
assert_eq!(body, "hello");
|
|
}
|
|
_ => panic!("Wrong variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_file_exchange_request() {
|
|
let req = FileExchangeRequest::Offer {
|
|
filename: "test.txt".to_string(),
|
|
size: 42,
|
|
hash: "abc123".to_string(),
|
|
};
|
|
let data = serde_json::to_vec(&req).unwrap();
|
|
let decoded: FileExchangeRequest = serde_json::from_slice(&data).unwrap();
|
|
match decoded {
|
|
FileExchangeRequest::Offer { filename, size, hash } => {
|
|
assert_eq!(filename, "test.txt");
|
|
assert_eq!(size, 42);
|
|
assert_eq!(hash, "abc123");
|
|
}
|
|
_ => panic!("Wrong variant"),
|
|
}
|
|
}
|
|
} |