use std::collections::HashMap;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use std::sync::RwLock as StdRwLock;
use tokio::net::TcpStream;
use tokio::sync::broadcast;
#[cfg(test)]
use tokio::sync::RwLock;
use crate::envelope::codec::{EnvelopeCodec, JsonCodec};
use crate::envelope::{Envelope, EnvelopeError};
use crate::file_transfer::{self, FileTransferState};
use crate::identity::{self, AppId, DeviceId, DeviceName};
use crate::network::tailscale::{TailscaleConfig, TailscaleProvider};
use crate::network::{DialOpts, HealthInfo, ListenOpts, NetworkProvider, NodeIdentity, PingResult};
use crate::session::{PeerEvent, PeerRegistry, PeerState};
use crate::transport::quic::{QuicConnection, QuicListener};
use crate::transport::websocket::WebSocketTransport;
use crate::transport::{DatagramSocket, RawListener, WsConfig};
#[derive(Debug, Clone)]
pub struct NamespacedMessage {
pub from: String,
pub namespace: String,
pub msg_type: String,
pub payload: serde_json::Value,
pub timestamp: Option<u64>,
}
impl NamespacedMessage {
pub fn payload_bytes(&self) -> Option<Vec<u8>> {
if self.msg_type != "bytes" {
return None;
}
let s = self.payload.get("data")?.as_str()?;
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.decode(s).ok()
}
}
#[derive(Debug, Clone)]
pub struct Peer {
pub device_id: Option<String>,
pub device_name: Option<String>,
pub display_name: String,
pub hostname: String,
pub tailscale_id: String,
pub peer_ref: String,
pub generation: u64,
pub ip: IpAddr,
pub online: bool,
pub ws_connected: bool,
pub connection_type: String,
pub os: Option<String>,
pub last_seen: Option<String>,
}
impl From<PeerState> for Peer {
fn from(s: PeerState) -> Self {
let (device_id, device_name, os_from_hello) = if s.identity_suppressed {
(None, None, None)
} else {
match s.identity.as_ref() {
Some(identity) => (
Some(identity.device_id.clone()),
Some(identity.device_name.clone()),
Some(identity.os.clone()),
),
None => (None, None, None),
}
};
debug_assert!(
device_id
.as_ref()
.map(|d| d.as_str() != s.id.as_str())
.unwrap_or(true),
"RFC 022 I1 violated: device_id must not equal tailscale_id"
);
let display_name = device_name
.clone()
.filter(|n| !n.is_empty())
.unwrap_or_else(|| display_name_from_hostname(&s.name, &s.id));
let peer_ref = s.peer_ref();
let generation = s.generation;
let os = os_from_hello.or(s.os.clone());
Self {
device_id,
device_name,
display_name,
hostname: s.name,
tailscale_id: s.id,
peer_ref,
generation,
ip: s.ip,
online: s.online,
ws_connected: s.ws_connected,
connection_type: s.connection_type,
os,
last_seen: s.last_seen,
}
}
}
fn display_name_from_hostname(hostname: &str, tailscale_id: &str) -> String {
const PREFIX: &str = "truffle-";
if let Some(rest) = hostname.strip_prefix(PREFIX) {
if let Some((_, slug)) = rest.split_once('-') {
if !slug.is_empty() {
return slug.to_string();
}
}
}
if !hostname.is_empty() {
return hostname.to_string();
}
let short: String = tailscale_id.chars().take(8).collect();
if short.is_empty() {
"peer".to_string()
} else {
short
}
}
#[derive(Debug, thiserror::Error)]
pub enum NodeError {
#[error("peer not found: {0}")]
PeerNotFound(String),
#[error("ambiguous peer query '{query}': {} candidates", candidates.len())]
AmbiguousPeer {
query: String,
candidates: Vec<String>,
},
#[error("peer gone: {0}")]
PeerGone(String),
#[error("connection failed: {0}")]
ConnectionFailed(String),
#[error("send failed: {0}")]
SendFailed(String),
#[error("envelope error: {0}")]
Envelope(#[from] EnvelopeError),
#[error("session error: {0}")]
Session(#[from] crate::session::SessionError),
#[error("network error: {0}")]
Network(#[from] crate::network::NetworkError),
#[error("transport error: {0}")]
Transport(#[from] crate::transport::TransportError),
#[error("not implemented: {0}")]
NotImplemented(String),
#[error("port {0} is reserved by truffle (the session WebSocket port)")]
ReservedPort(u16),
#[error("node stopped")]
Stopped,
#[error("build error: {0}")]
BuildError(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
pub struct Node<N: NetworkProvider + 'static> {
pub(crate) network: Arc<N>,
session: Arc<PeerRegistry<N>>,
codec: Arc<dyn EnvelopeCodec>,
#[allow(dead_code)]
incoming_tx: broadcast::Sender<NamespacedMessage>,
namespace_filters: Arc<StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>>,
pub(crate) file_transfer_state: FileTransferState,
state_dir: PathBuf,
pub(crate) ws_port: u16,
pub(crate) proxy_state: Arc<crate::proxy::ProxyState>,
stopped: std::sync::atomic::AtomicBool,
pub(crate) tasks: NodeTasks,
}
pub(crate) struct NodeTasks {
pub(crate) cancel: tokio_util::sync::CancellationToken,
pub(crate) tracker: tokio_util::task::TaskTracker,
long_lived: std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>,
}
impl NodeTasks {
fn new() -> Self {
Self {
cancel: tokio_util::sync::CancellationToken::new(),
tracker: tokio_util::task::TaskTracker::new(),
long_lived: std::sync::Mutex::new(Vec::new()),
}
}
fn spawn_long_lived<F>(&self, fut: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
let handle = self.tracker.spawn(fut);
self.long_lived.lock().unwrap().push(handle);
}
fn abort_long_lived(&self) {
for h in self.long_lived.lock().unwrap().drain(..) {
h.abort();
}
}
}
impl<N: NetworkProvider + 'static> Node<N> {
pub(crate) fn from_parts(
network: Arc<N>,
session: Arc<PeerRegistry<N>>,
codec: Arc<dyn EnvelopeCodec>,
) -> Self {
let (incoming_tx, _) = broadcast::channel(1024);
let namespace_filters: Arc<
StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>,
> = Arc::new(StdRwLock::new(HashMap::new()));
let node = Self {
network,
session: session.clone(),
codec: codec.clone(),
incoming_tx: incoming_tx.clone(),
namespace_filters: namespace_filters.clone(),
file_transfer_state: FileTransferState::new(),
state_dir: PathBuf::new(),
ws_port: 9417,
proxy_state: Arc::new(crate::proxy::ProxyState::new()),
stopped: std::sync::atomic::AtomicBool::new(false),
tasks: NodeTasks::new(),
};
node.spawn_envelope_router(session, codec, incoming_tx, namespace_filters);
node.spawn_proxy_error_forwarder();
node
}
fn spawn_proxy_error_forwarder(&self) {
let Some(mut rx) = self.network.proxy_runtime_errors() else {
return;
};
let proxy_state = self.proxy_state.clone();
let cancel = self.tasks.cancel.clone();
self.tasks.spawn_long_lived(async move {
loop {
tokio::select! {
_ = cancel.cancelled() => {
tracing::debug!("proxy error forwarder: cancelled by stop()");
return;
}
event = rx.recv() => match event {
Ok(e) => proxy_state.record_runtime_error(e.id, e.code, e.message),
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("proxy error forwarder lagged by {n} events");
}
Err(broadcast::error::RecvError::Closed) => return,
}
}
}
});
}
fn spawn_envelope_router(
&self,
session: Arc<PeerRegistry<N>>,
codec: Arc<dyn EnvelopeCodec>,
incoming_tx: broadcast::Sender<NamespacedMessage>,
namespace_filters: Arc<StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>>,
) {
let mut rx = session.subscribe();
let cancel = self.tasks.cancel.clone();
self.tasks.spawn_long_lived(async move {
loop {
let recv = tokio::select! {
_ = cancel.cancelled() => {
tracing::debug!("envelope router: cancelled by stop()");
break;
}
result = rx.recv() => result,
};
match recv {
Ok(msg) => {
if let Ok(envelope) = codec.decode(&msg.data) {
let namespaced = NamespacedMessage {
from: msg.from,
namespace: envelope.namespace.clone(),
msg_type: envelope.msg_type,
payload: envelope.payload,
timestamp: envelope.timestamp,
};
tracing::debug!(
from = %namespaced.from,
namespace = %namespaced.namespace,
msg_type = %namespaced.msg_type,
"envelope router: dispatching message"
);
let _ = incoming_tx.send(namespaced.clone());
let namespace = namespaced.namespace.clone();
let dead_subscriber = {
let filters = namespace_filters
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match filters.get(&namespace) {
Some(tx) => {
let send_result = tx.send(namespaced);
tracing::debug!(
namespace = %namespace,
subscriber_count = tx.receiver_count(),
sent = send_result.is_ok(),
"envelope router: sent to namespace subscriber"
);
send_result.is_err()
}
None => {
tracing::debug!(
namespace = %namespace,
"envelope router: no subscriber for namespace"
);
false
}
}
};
if dead_subscriber {
let mut filters = namespace_filters
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if filters
.get(&namespace)
.is_some_and(|tx| tx.receiver_count() == 0)
{
filters.remove(&namespace);
tracing::debug!(
namespace = %namespace,
"envelope router: pruned dead namespace subscriber"
);
}
}
} else {
tracing::warn!(
from = %msg.from,
data_len = msg.data.len(),
"node: failed to decode envelope from incoming message"
);
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
missed = n,
"node: envelope router lagged, missed {n} messages"
);
continue;
}
Err(broadcast::error::RecvError::Closed) => {
tracing::debug!("node: session incoming channel closed, router exiting");
break;
}
}
}
});
}
pub fn builder() -> NodeBuilder {
NodeBuilder::default()
}
pub fn file_transfer(&self) -> file_transfer::FileTransfer<'_, N> {
file_transfer::FileTransfer::new(self)
}
pub fn proxy(&self) -> crate::proxy::Proxy<'_, N> {
crate::proxy::Proxy::new(self)
}
pub fn synced_store<T>(
self: &Arc<Self>,
store_id: &str,
) -> Arc<crate::synced_store::SyncedStore<T>>
where
T: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
{
crate::synced_store::SyncedStore::new(self.clone(), store_id)
}
pub fn synced_store_with_backend<T>(
self: &Arc<Self>,
store_id: &str,
backend: std::sync::Arc<dyn crate::synced_store::StoreBackend>,
) -> Arc<crate::synced_store::SyncedStore<T>>
where
T: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
{
crate::synced_store::SyncedStore::new_with_backend(self.clone(), store_id, backend)
}
pub(crate) fn with_state_dir(mut self, dir: PathBuf) -> Self {
self.state_dir = dir;
self
}
pub(crate) fn with_ws_port(mut self, port: u16) -> Self {
self.ws_port = port;
self
}
pub fn state_dir(&self) -> &Path {
&self.state_dir
}
pub async fn stop(&self) {
if self.stopped.swap(true, std::sync::atomic::Ordering::SeqCst) {
tracing::debug!("node: stop() called on already-stopped node");
return;
}
tracing::info!("node: stopping");
self.tasks.cancel.cancel();
if let Some(h) = self.file_transfer_state.receiver_handle.lock().await.take() {
h.abort();
}
self.session.shutdown().await;
if let Err(e) = self.network.stop().await {
tracing::warn!(error = %e, "node: network provider stop failed");
}
self.tasks.tracker.close();
let drain =
tokio::time::timeout(std::time::Duration::from_secs(5), self.tasks.tracker.wait());
if drain.await.is_err() {
tracing::warn!("node: background tasks did not drain in 5s; aborting stragglers");
self.tasks.abort_long_lived();
let _ =
tokio::time::timeout(std::time::Duration::from_secs(1), self.tasks.tracker.wait())
.await;
}
tracing::info!("node: stopped");
}
pub fn local_info(&self) -> NodeIdentity {
self.network.local_identity()
}
pub async fn peers(&self) -> Vec<Peer> {
let app_id = self.network.local_identity().app_id;
self.session
.peers()
.await
.into_iter()
.map(|s| Self::project_peer(s, &app_id))
.collect()
}
fn project_peer(s: PeerState, app_id: &str) -> Peer {
let bare = s
.identity
.is_none()
.then(|| hostname_slug(&s.name, app_id).map(str::to_string))
.flatten();
let mut peer = Peer::from(s);
if let Some(bare) = bare {
peer.display_name = bare;
}
peer
}
pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
self.session.on_peer_change()
}
pub async fn peer(&self, query: &str, wait_ms: Option<u64>) -> Result<Option<Peer>, NodeError> {
match self.resolve_peer(query).await {
Ok(state) => {
let app_id = self.network.local_identity().app_id;
return Ok(Some(Self::project_peer(state, &app_id)));
}
Err(NodeError::PeerNotFound(_)) => {}
Err(e) => return Err(e),
}
let Some(ms) = wait_ms.filter(|m| *m > 0) else {
return Ok(None);
};
let mut rx = self.session.on_peer_change();
match self.resolve_peer(query).await {
Ok(state) => {
let app_id = self.network.local_identity().app_id;
return Ok(Some(Self::project_peer(state, &app_id)));
}
Err(NodeError::PeerNotFound(_)) => {}
Err(e) => return Err(e),
}
let deadline = tokio::time::Instant::now() + Duration::from_millis(ms);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Ok(None);
}
match tokio::time::timeout(remaining, rx.recv()).await {
Ok(Ok(_)) => match self.resolve_peer(query).await {
Ok(state) => {
let app_id = self.network.local_identity().app_id;
return Ok(Some(Self::project_peer(state, &app_id)));
}
Err(NodeError::PeerNotFound(_)) => continue,
Err(e) => return Err(e),
},
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return Ok(None),
Err(_) => return Ok(None), }
}
}
pub(crate) async fn resolve_peer(&self, peer_ref: &str) -> Result<PeerState, NodeError> {
let peers = self.session.peers().await;
let app_id = self.network.local_identity().app_id;
for p in &peers {
if let Some(uid) = p.published_device_id() {
if uid == peer_ref {
return Ok(p.clone());
}
}
if p.id == peer_ref || p.name == peer_ref || p.ip.to_string() == peer_ref {
return Ok(p.clone());
}
if p.peer_ref() == peer_ref {
return Ok(p.clone());
}
}
let name_hits: Vec<&PeerState> = peers
.iter()
.filter(|p| {
p.identity
.as_ref()
.map(|i| !p.identity_suppressed && i.device_name.eq_ignore_ascii_case(peer_ref))
.unwrap_or(false)
})
.collect();
if name_hits.len() > 1 {
return Err(NodeError::AmbiguousPeer {
query: peer_ref.to_string(),
candidates: name_hits
.iter()
.map(|p| p.published_device_id().unwrap_or(p.id.as_str()).to_string())
.collect(),
});
}
if let Some(hit) = name_hits.first() {
return Ok((*hit).clone());
}
let ref_slug = identity::slug(peer_ref, 255);
if !ref_slug.is_empty() {
let slug_hits: Vec<&PeerState> = peers
.iter()
.filter(|p| hostname_slug(&p.name, &app_id) == Some(ref_slug.as_str()))
.collect();
if slug_hits.len() > 1 {
return Err(NodeError::AmbiguousPeer {
query: peer_ref.to_string(),
candidates: slug_hits.iter().map(|p| p.id.clone()).collect(),
});
}
if let Some(hit) = slug_hits.first() {
return Ok((*hit).clone());
}
}
if peer_ref.len() >= 4 {
let hits: Vec<&PeerState> = peers
.iter()
.filter(|p| {
p.published_device_id()
.map(|uid| uid.starts_with(peer_ref))
.unwrap_or(false)
})
.collect();
if hits.len() > 1 {
return Err(NodeError::AmbiguousPeer {
query: peer_ref.to_string(),
candidates: hits
.iter()
.filter_map(|p| p.published_device_id().map(|s| s.to_string()))
.collect(),
});
}
if let Some(hit) = hits.first() {
return Ok((*hit).clone());
}
}
if crate::session::parse_peer_ref(peer_ref).is_some() {
return Err(NodeError::PeerGone(peer_ref.to_string()));
}
Err(NodeError::PeerNotFound(peer_ref.to_string()))
}
pub async fn resolve_peer_ip(&self, peer_ref: &str) -> Result<IpAddr, NodeError> {
self.ensure_not_stopped()?;
Ok(self.resolve_peer(peer_ref).await?.ip)
}
pub async fn resolve_peer_id(&self, peer_id: &str) -> Result<String, NodeError> {
self.ensure_not_stopped()?;
let p = self.resolve_peer(peer_id).await?;
Ok(p.published_device_id()
.map(|s| s.to_string())
.unwrap_or_else(|| p.id.clone()))
}
pub async fn ping(&self, peer_id: &str) -> Result<PingResult, NodeError> {
self.ensure_not_stopped()?;
let peer = self.resolve_peer(peer_id).await?;
let addr = peer.ip.to_string();
self.network.ping(&addr).await.map_err(NodeError::Network)
}
pub async fn health(&self) -> HealthInfo {
self.network.health().await
}
fn ensure_not_stopped(&self) -> Result<(), NodeError> {
if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
return Err(NodeError::Stopped);
}
Ok(())
}
fn map_session_send_err(e: crate::session::SessionError) -> NodeError {
match e {
crate::session::SessionError::PeerGone(r) => NodeError::PeerGone(r),
e => NodeError::Session(e),
}
}
#[deprecated(
since = "0.7.0",
note = "wire type depends on data contents; use send_json or send_bytes"
)]
pub async fn send(&self, peer_id: &str, namespace: &str, data: &[u8]) -> Result<(), NodeError> {
self.ensure_not_stopped()?;
let payload = std::str::from_utf8(data)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
let encoded = self.codec.encode(&envelope)?;
self.session
.send(peer_id, &encoded)
.await
.map_err(Self::map_session_send_err)?;
Ok(())
}
pub async fn send_json(
&self,
peer_id: &str,
namespace: &str,
payload: &serde_json::Value,
) -> Result<(), NodeError> {
self.send_typed(peer_id, namespace, "message", payload)
.await
}
pub async fn send_bytes(
&self,
peer_id: &str,
namespace: &str,
data: &[u8],
) -> Result<(), NodeError> {
let payload = Self::bytes_payload(data);
self.send_typed(peer_id, namespace, "bytes", &payload).await
}
fn bytes_payload(data: &[u8]) -> serde_json::Value {
use base64::Engine as _;
serde_json::json!({
"encoding": "base64",
"data": base64::engine::general_purpose::STANDARD.encode(data),
})
}
pub async fn send_typed(
&self,
peer_id: &str,
namespace: &str,
msg_type: &str,
payload: &serde_json::Value,
) -> Result<(), NodeError> {
self.ensure_not_stopped()?;
let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
let encoded = self.codec.encode(&envelope)?;
self.session
.send(peer_id, &encoded)
.await
.map_err(Self::map_session_send_err)?;
Ok(())
}
pub async fn broadcast_typed(
&self,
namespace: &str,
msg_type: &str,
payload: &serde_json::Value,
) {
if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
tracing::debug!("node: broadcast after stop ignored");
return;
}
let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
match self.codec.encode(&envelope) {
Ok(encoded) => {
self.session.broadcast(&encoded).await;
}
Err(e) => {
tracing::error!("node: failed to encode broadcast envelope: {e}");
}
}
}
#[deprecated(
since = "0.7.0",
note = "wire type depends on data contents and failures are hidden; \
use broadcast_json or broadcast_bytes"
)]
pub async fn broadcast(&self, namespace: &str, data: &[u8]) {
if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
tracing::debug!("node: broadcast after stop ignored");
return;
}
let payload = std::str::from_utf8(data)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
match self.codec.encode(&envelope) {
Ok(encoded) => {
self.session.broadcast(&encoded).await;
}
Err(e) => {
tracing::error!("node: failed to encode broadcast envelope: {e}");
}
}
}
pub async fn broadcast_json(
&self,
namespace: &str,
payload: &serde_json::Value,
) -> Result<crate::session::BroadcastReport, NodeError> {
self.broadcast_reported(namespace, "message", payload).await
}
pub async fn broadcast_bytes(
&self,
namespace: &str,
data: &[u8],
) -> Result<crate::session::BroadcastReport, NodeError> {
let payload = Self::bytes_payload(data);
self.broadcast_reported(namespace, "bytes", &payload).await
}
async fn broadcast_reported(
&self,
namespace: &str,
msg_type: &str,
payload: &serde_json::Value,
) -> Result<crate::session::BroadcastReport, NodeError> {
self.ensure_not_stopped()?;
let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
let encoded = self.codec.encode(&envelope)?;
Ok(self.session.broadcast(&encoded).await)
}
pub fn subscribe(&self, namespace: &str) -> broadcast::Receiver<NamespacedMessage> {
{
let filters = self
.namespace_filters
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(tx) = filters.get(namespace) {
return tx.subscribe();
}
}
let mut filters = self
.namespace_filters
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(tx) = filters.get(namespace) {
return tx.subscribe();
}
filters.retain(|_, tx| tx.receiver_count() > 0);
let (tx, rx) = broadcast::channel(256);
filters.insert(namespace.to_string(), tx);
rx
}
pub async fn open_tcp(&self, peer_id: &str, port: u16) -> Result<TcpStream, NodeError> {
self.ensure_not_stopped()?;
let peer = self.resolve_peer(peer_id).await?;
let addr = peer.ip.to_string();
self.network
.dial_tcp_opts(&addr, port, DialOpts { tls: Some(false) })
.await
.map_err(|e| NodeError::ConnectionFailed(e.to_string()))
}
pub async fn listen_tcp(&self, port: u16) -> Result<RawListener, NodeError> {
self.listen_tcp_opts(port, ListenOpts::default()).await
}
pub async fn listen_tcp_opts(
&self,
port: u16,
opts: ListenOpts,
) -> Result<RawListener, NodeError> {
self.ensure_not_stopped()?;
use crate::transport::tcp::TcpTransport;
ensure_port_unreserved(port, self.ws_port)?;
let tcp = TcpTransport::new(self.network.clone());
tcp.listen_opts(port, opts).await.map_err(|e| {
if port == 443 {
NodeError::ConnectionFailed(format!(
"{e} (port 443 requires a sidecar built with RFC 023 — older sidecars bind \
a legacy TLS listener on 443; upgrade the sidecar binary)"
))
} else {
NodeError::Transport(e)
}
})
}
pub async fn unlisten_tcp(&self, port: u16) -> Result<(), NodeError> {
self.ensure_not_stopped()?;
self.network
.unlisten_tcp(port)
.await
.map_err(NodeError::Network)
}
pub async fn connect_quic(
&self,
peer_id: &str,
port: u16,
) -> Result<QuicConnection, NodeError> {
self.ensure_not_stopped()?;
let peer = self.resolve_peer(peer_id).await?;
let alpn = crate::transport::quic::raw_alpn(&self.network.local_identity().app_id);
crate::transport::quic::connect_raw(&self.network, &peer.ip.to_string(), port, &alpn)
.await
.map_err(NodeError::Transport)
}
pub async fn listen_quic(&self, port: u16) -> Result<QuicListener, NodeError> {
self.ensure_not_stopped()?;
ensure_port_unreserved(port, self.ws_port)?;
if port == 0 {
return Err(NodeError::NotImplemented(
"ephemeral (port 0) QUIC listeners are not supported over the tsnet relay yet — choose an explicit port"
.to_string(),
));
}
let alpn = crate::transport::quic::raw_alpn(&self.network.local_identity().app_id);
crate::transport::quic::listen_raw(&self.network, port, &alpn)
.await
.map_err(NodeError::Transport)
}
pub async fn bind_udp(&self, port: u16) -> Result<DatagramSocket, NodeError> {
self.ensure_not_stopped()?;
use crate::transport::udp::{UdpConfig, UdpTransport};
use crate::transport::DatagramTransport;
let udp = UdpTransport::new(self.network.clone(), UdpConfig::default());
udp.bind(port).await.map_err(NodeError::Transport)
}
}
impl<N: NetworkProvider + 'static> Drop for Node<N> {
fn drop(&mut self) {
self.tasks.cancel.cancel();
}
}
fn hostname_slug<'a>(hostname: &'a str, app_id: &str) -> Option<&'a str> {
hostname
.strip_prefix("truffle-")?
.strip_prefix(app_id)?
.strip_prefix('-')
}
fn validate_hostname_label(s: &str) -> Result<(), String> {
let ok_len = (1..=63).contains(&s.len());
let ok_chars = s
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
let ok_edges = !s.starts_with('-') && !s.ends_with('-');
if ok_len && ok_chars && ok_edges {
Ok(())
} else {
Err(format!(
"invalid hostname {s:?}: must be a single lowercase DNS label \
(1-63 chars of [a-z0-9-], no leading/trailing hyphen, no dots)"
))
}
}
pub(crate) fn ensure_port_unreserved(port: u16, ws_port: u16) -> Result<(), NodeError> {
if port == ws_port {
Err(NodeError::ReservedPort(port))
} else {
Ok(())
}
}
#[derive(Clone)]
pub struct NodeBuilder {
app_id: Option<AppId>,
device_name: Option<DeviceName>,
device_id: Option<DeviceId>,
hostname: Option<String>,
sidecar_path: Option<PathBuf>,
state_dir: Option<String>,
auth_key: Option<String>,
ephemeral: bool,
ws_port: u16,
idle_timeout_secs: Option<u64>,
eager_identity: bool,
}
impl std::fmt::Debug for NodeBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NodeBuilder")
.field("app_id", &self.app_id)
.field("device_name", &self.device_name)
.field("device_id", &self.device_id)
.field("hostname", &self.hostname)
.field("sidecar_path", &self.sidecar_path)
.field("state_dir", &self.state_dir)
.field("auth_key", &self.auth_key.as_ref().map(|_| "[REDACTED]"))
.field("ephemeral", &self.ephemeral)
.field("ws_port", &self.ws_port)
.field("idle_timeout_secs", &self.idle_timeout_secs)
.field("eager_identity", &self.eager_identity)
.finish()
}
}
fn atomic_write_string(path: &Path, content: &str) -> std::io::Result<()> {
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"path has no parent directory",
)
})?;
let mut tmp = path.to_path_buf();
tmp.set_extension("tmp");
{
use std::io::Write as _;
let mut f = std::fs::File::create(&tmp)?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
}
#[cfg(unix)]
{
std::fs::rename(&tmp, path)?;
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
}
#[cfg(windows)]
{
let _ = parent; replace_file_windows(&tmp, path)?;
}
Ok(())
}
#[cfg(windows)]
fn replace_file_windows(tmp: &Path, dest: &Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
#[link(name = "kernel32")]
extern "system" {
fn MoveFileExW(from: *const u16, to: *const u16, flags: u32) -> i32;
}
fn wide(p: &Path) -> Vec<u16> {
p.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
let (from, to) = (wide(tmp), wide(dest));
let ok = unsafe {
MoveFileExW(
from.as_ptr(),
to.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if ok == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
impl Default for NodeBuilder {
fn default() -> Self {
Self {
app_id: None,
device_name: None,
device_id: None,
sidecar_path: None,
state_dir: None,
auth_key: None,
hostname: None,
ephemeral: false,
ws_port: 9417,
idle_timeout_secs: None,
eager_identity: true,
}
}
}
impl NodeBuilder {
pub fn app_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
let raw: String = s.into();
let app_id = AppId::parse(&raw)
.map_err(|e| NodeError::BuildError(format!("invalid app_id: {e}")))?;
self.app_id = Some(app_id);
Ok(self)
}
pub fn device_name(mut self, s: impl Into<String>) -> Self {
self.device_name = Some(DeviceName::parse(s));
self
}
pub fn hostname(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
let raw: String = s.into();
validate_hostname_label(&raw).map_err(NodeError::BuildError)?;
self.hostname = Some(raw);
Ok(self)
}
pub fn device_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
let raw: String = s.into();
let device_id = DeviceId::parse(&raw)
.map_err(|e| NodeError::BuildError(format!("invalid device_id: {e}")))?;
self.device_id = Some(device_id);
Ok(self)
}
pub fn sidecar_path(mut self, path: impl Into<PathBuf>) -> Self {
self.sidecar_path = Some(path.into());
self
}
pub fn state_dir(mut self, dir: &str) -> Self {
self.state_dir = Some(dir.to_string());
self
}
pub fn auth_key(mut self, key: &str) -> Self {
self.auth_key = Some(key.to_string());
self
}
pub fn ephemeral(mut self, val: bool) -> Self {
self.ephemeral = val;
self
}
pub fn eager_identity(mut self, enabled: bool) -> Self {
self.eager_identity = enabled;
self
}
pub fn ws_port(mut self, port: u16) -> Self {
self.ws_port = port;
self
}
pub fn idle_timeout_secs(mut self, secs: u64) -> Self {
self.idle_timeout_secs = Some(secs);
self
}
fn prepare_config(&self) -> Result<TailscaleConfig, NodeError> {
let binary_path = self
.sidecar_path
.clone()
.ok_or_else(|| NodeError::BuildError("sidecar_path is required".into()))?;
let app_id = self
.app_id
.clone()
.ok_or_else(|| NodeError::BuildError("app_id is required".into()))?;
let device_name = match self.device_name.clone() {
Some(name) => name,
None => {
let os_hostname = hostname::get()
.map_err(|e| {
NodeError::BuildError(format!(
"device_name is unset and hostname::get() failed: {e}"
))
})?
.to_string_lossy()
.into_owned();
DeviceName::parse(os_hostname)
}
};
let tailscale_host = match self.hostname.clone() {
Some(hostname) => hostname,
None => identity::tailscale_hostname(&app_id, &device_name),
};
let state_dir = match self.state_dir.clone() {
Some(dir) => dir,
None => {
let base = dirs::data_dir().ok_or_else(|| {
NodeError::BuildError(
"no platform data directory available (dirs::data_dir() returned None); \
set state_dir explicitly to a durable location"
.into(),
)
})?;
base.join("truffle")
.join(app_id.as_str())
.join(identity::slug(device_name.as_str(), 255))
.to_string_lossy()
.into_owned()
}
};
std::fs::create_dir_all(&state_dir)?;
let device_id_file = Path::new(&state_dir).join("device-id.txt");
let device_id = match self.device_id.clone() {
Some(id) => {
atomic_write_string(&device_id_file, id.as_str())?;
id
}
None => {
if device_id_file.exists() {
let s = std::fs::read_to_string(&device_id_file)?.trim().to_string();
DeviceId::parse(&s).map_err(|e| {
NodeError::BuildError(format!(
"device-id.txt at {device_id_file:?} contains an invalid ULID: {e}"
))
})?
} else {
let id = DeviceId::generate();
atomic_write_string(&device_id_file, id.as_str())?;
id
}
}
};
Ok(TailscaleConfig {
binary_path,
app_id: app_id.as_str().to_string(),
device_id: device_id.as_str().to_string(),
device_name: device_name.as_str().to_string(),
hostname: tailscale_host,
state_dir,
auth_key: self.auth_key.clone(),
ephemeral: if self.ephemeral { Some(true) } else { None },
tags: None,
idle_timeout_secs: self.idle_timeout_secs,
})
}
pub async fn build(self) -> Result<Node<TailscaleProvider>, NodeError> {
let ws_port = self.ws_port;
let eager_identity = self.eager_identity;
let config = self.prepare_config()?;
let state_dir = PathBuf::from(&config.state_dir);
let mut provider = TailscaleProvider::new(config);
provider.start().await.map_err(NodeError::Network)?;
let network = Arc::new(provider);
let ws_config = WsConfig {
port: ws_port,
..Default::default()
};
let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
let session = Arc::new(PeerRegistry::with_options(
network.clone(),
ws_transport,
crate::session::PeerRegistryOptions {
eager_identity,
..Default::default()
},
));
session.start().await;
let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
let node = Node::from_parts(network, session, codec)
.with_state_dir(state_dir)
.with_ws_port(ws_port);
tracing::info!("node: started successfully");
Ok(node)
}
pub async fn build_with_auth_handler(
self,
on_auth: impl Fn(String) + Send + 'static,
) -> Result<Node<TailscaleProvider>, NodeError> {
let ws_port = self.ws_port;
let eager_identity = self.eager_identity;
let config = self.prepare_config()?;
let state_dir = PathBuf::from(&config.state_dir);
let mut provider = TailscaleProvider::new(config);
let mut auth_rx = provider.peer_events();
let auth_task = tokio::spawn(async move {
use crate::network::NetworkPeerEvent;
loop {
match auth_rx.recv().await {
Ok(NetworkPeerEvent::AuthRequired { url }) => {
on_auth(url);
}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
_ => {} }
}
});
let start_result = provider.start().await.map_err(NodeError::Network);
auth_task.abort();
start_result?;
let network = Arc::new(provider);
let ws_config = WsConfig {
port: ws_port,
..Default::default()
};
let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
let session = Arc::new(PeerRegistry::with_options(
network.clone(),
ws_transport,
crate::session::PeerRegistryOptions {
eager_identity,
..Default::default()
},
));
session.start().await;
let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
let node = Node::from_parts(network, session, codec)
.with_state_dir(state_dir)
.with_ws_port(ws_port);
tracing::info!("node: started successfully (with auth handler)");
Ok(node)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::{
HealthInfo, IncomingConnection, NetworkError, NetworkPeer, NetworkPeerEvent,
NetworkTcpListener, NetworkUdpSocket, PeerAddr,
};
use crate::transport::WsConfig;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{broadcast, mpsc};
#[test]
fn node_builder_debug_redacts_auth_key() {
let builder = NodeBuilder::default().auth_key("dummy-auth-SECRET123");
let dbg = format!("{builder:?}");
assert!(!dbg.contains("SECRET123"));
assert!(dbg.contains("[REDACTED]"));
}
#[test]
fn atomic_write_string_creates_and_replaces() {
let dir =
std::env::temp_dir().join(format!("truffle-atomic-write-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("device-id.txt");
atomic_write_string(&path, "01AAAAAAAAAAAAAAAAAAAAAAAA").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"01AAAAAAAAAAAAAAAAAAAAAAAA"
);
atomic_write_string(&path, "01BBBBBBBBBBBBBBBBBBBBBBBB").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"01BBBBBBBBBBBBBBBBBBBBBBBB"
);
std::fs::remove_dir_all(&dir).ok();
}
struct MockNetworkProvider {
identity: NodeIdentity,
local_addr: PeerAddr,
peer_event_tx: broadcast::Sender<NetworkPeerEvent>,
mock_peers: Arc<RwLock<Vec<NetworkPeer>>>,
stop_calls: Arc<AtomicUsize>,
}
impl MockNetworkProvider {
fn new(id: &str) -> Self {
Self::new_with_device_id(id, id)
}
fn new_with_device_id(id: &str, device_id: &str) -> Self {
let (peer_event_tx, _) = broadcast::channel(64);
Self {
identity: NodeIdentity {
app_id: "test".to_string(),
device_id: device_id.to_string(),
device_name: format!("Test Node {id}"),
tailscale_hostname: format!("truffle-test-{id}"),
tailscale_id: id.to_string(),
dns_name: None,
ip: Some("127.0.0.1".parse().unwrap()),
},
local_addr: PeerAddr {
ip: Some("127.0.0.1".parse().unwrap()),
hostname: format!("truffle-test-{id}"),
dns_name: None,
},
peer_event_tx,
mock_peers: Arc::new(RwLock::new(Vec::new())),
stop_calls: Arc::new(AtomicUsize::new(0)),
}
}
fn event_sender(&self) -> broadcast::Sender<NetworkPeerEvent> {
self.peer_event_tx.clone()
}
fn stop_call_count(&self) -> usize {
self.stop_calls.load(Ordering::SeqCst)
}
}
impl NetworkProvider for MockNetworkProvider {
async fn start(&mut self) -> Result<(), NetworkError> {
Ok(())
}
async fn stop(&self) -> Result<(), NetworkError> {
self.stop_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn local_identity(&self) -> NodeIdentity {
self.identity.clone()
}
fn local_addr(&self) -> PeerAddr {
self.local_addr.clone()
}
fn peer_events(&self) -> broadcast::Receiver<NetworkPeerEvent> {
self.peer_event_tx.subscribe()
}
async fn peers(&self) -> Vec<NetworkPeer> {
self.mock_peers.read().await.clone()
}
async fn dial_tcp(&self, addr: &str, port: u16) -> Result<TcpStream, NetworkError> {
let target = format!("{addr}:{port}");
TcpStream::connect(&target)
.await
.map_err(|e| NetworkError::DialFailed(format!("mock dial {target}: {e}")))
}
async fn listen_tcp(&self, port: u16) -> Result<NetworkTcpListener, NetworkError> {
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.await
.map_err(|e| NetworkError::ListenFailed(format!("mock listen :{port}: {e}")))?;
let actual_port = listener.local_addr().unwrap().port();
let (tx, rx) = mpsc::channel::<IncomingConnection>(64);
tokio::spawn(async move {
loop {
match listener.accept().await {
Ok((stream, addr)) => {
let conn = IncomingConnection {
stream,
remote_addr: addr.to_string(),
remote_identity: String::new(),
port: actual_port,
};
if tx.send(conn).await.is_err() {
break;
}
}
Err(e) => {
tracing::debug!("mock listener error: {e}");
break;
}
}
}
});
Ok(NetworkTcpListener {
port: actual_port,
incoming: rx,
})
}
async fn unlisten_tcp(&self, _port: u16) -> Result<(), NetworkError> {
Ok(())
}
async fn bind_udp(&self, _port: u16) -> Result<NetworkUdpSocket, NetworkError> {
Err(NetworkError::Internal("mock: UDP not supported".into()))
}
async fn ping(&self, _addr: &str) -> Result<PingResult, NetworkError> {
Ok(PingResult {
latency: Duration::from_millis(1),
connection: "direct".to_string(),
peer_addr: None,
})
}
async fn health(&self) -> HealthInfo {
HealthInfo {
state: "running".to_string(),
healthy: true,
..Default::default()
}
}
}
fn make_loopback_peer(id: &str) -> NetworkPeer {
NetworkPeer {
id: id.to_string(),
hostname: format!("truffle-test-{id}"),
ip: "127.0.0.1".parse().unwrap(),
online: true,
cur_addr: Some("127.0.0.1:41641".to_string()),
relay: None,
os: Some("linux".to_string()),
last_seen: Some("2026-03-25T12:00:00Z".to_string()),
key_expiry: None,
dns_name: None,
}
}
fn ws_config(port: u16) -> WsConfig {
WsConfig {
port,
ping_interval: Duration::from_secs(300),
pong_timeout: Duration::from_secs(300),
..Default::default()
}
}
async fn random_port() -> u16 {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
l.local_addr().unwrap().port()
}
async fn make_test_node(
id: &str,
ws_port: u16,
) -> (
Node<MockNetworkProvider>,
broadcast::Sender<NetworkPeerEvent>,
Arc<MockNetworkProvider>,
) {
make_test_node_with_device_id(id, id, ws_port).await
}
async fn make_test_node_with_device_id(
id: &str,
device_id: &str,
ws_port: u16,
) -> (
Node<MockNetworkProvider>,
broadcast::Sender<NetworkPeerEvent>,
Arc<MockNetworkProvider>,
) {
let provider = MockNetworkProvider::new_with_device_id(id, device_id);
let event_tx = provider.event_sender();
let network = Arc::new(provider);
let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config(ws_port)));
let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
session.start().await;
let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
let node = Node::from_parts(network.clone(), session, codec);
(node, event_tx, network)
}
#[tokio::test]
async fn offer_flood_respects_caps_and_stop_drains() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-flood", ws_port).await;
let node = Arc::new(node);
let mut offers = node.file_transfer().offer_channel(node.clone()).await;
for i in 0..500u32 {
let payload = json!({
"type": "offer",
"file_name": format!("f{i}.bin"),
"size": 1,
"sha256": "0".repeat(64),
"save_path": "",
"token": format!("tok-{i}"),
"tcp_port": 0,
});
let envelope = Envelope::new("ft", "offer", payload).with_timestamp();
node.session
.test_inject_incoming("peer-flood", JsonCodec.encode(&envelope).unwrap());
if i % 64 == 0 {
tokio::task::yield_now().await;
}
}
tokio::time::sleep(Duration::from_millis(300)).await;
let pending = node
.file_transfer_state
.pending_offers_per_peer
.lock()
.unwrap()
.get("peer-flood")
.copied()
.unwrap_or(0);
assert!(
pending <= crate::file_transfer::MAX_PENDING_OFFERS_PER_PEER,
"per-peer pending cap violated: {pending}"
);
let mut buffered = 0;
while offers.try_recv().is_ok() {
buffered += 1;
}
assert!(buffered <= 36, "app offer queue grew to {buffered}");
let started = std::time::Instant::now();
node.stop().await;
assert!(
started.elapsed() < Duration::from_secs(5),
"stop() took {:?} with parked offers",
started.elapsed()
);
assert!(node.tasks.tracker.is_empty());
}
#[tokio::test]
async fn dynamic_namespace_churn_stays_bounded() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-ns-churn", ws_port).await;
for i in 0..1000 {
let rx = node.subscribe(&format!("dyn-{i}"));
drop(rx);
}
let len = node
.namespace_filters
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len();
assert!(len <= 1, "namespace map grew to {len} entries");
}
#[tokio::test]
async fn lifecycle_churn_leaves_no_tasks() {
for _ in 0..25 {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-churn", ws_port).await;
let node = Arc::new(node);
let _rx = node.subscribe("churn");
let _offers = node.file_transfer().offer_channel(node.clone()).await;
node.stop().await;
assert!(node.tasks.tracker.is_empty(), "task leak after stop()");
}
}
#[tokio::test]
async fn slow_subscriber_lags_instead_of_growing() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-lag", ws_port).await;
let mut rx = node.subscribe("chat");
for i in 0..2000 {
let envelope = Envelope::new("chat", "message", json!({ "i": i })).with_timestamp();
node.session
.test_inject_incoming("peer-x", JsonCodec.encode(&envelope).unwrap());
if i % 100 == 0 {
tokio::task::yield_now().await;
}
}
tokio::time::sleep(Duration::from_millis(300)).await;
let mut lagged = false;
loop {
match rx.try_recv() {
Ok(_) => {}
Err(broadcast::error::TryRecvError::Lagged(_)) => lagged = true,
Err(_) => break,
}
}
assert!(lagged, "slow subscriber should observe Lagged");
}
#[tokio::test]
async fn stop_drains_all_background_tasks() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-drain", ws_port).await;
let node = Arc::new(node);
let _offers = node.file_transfer().offer_channel(node.clone()).await;
assert!(!node.tasks.tracker.is_empty(), "tasks should be running");
node.stop().await;
assert!(
node.tasks.tracker.is_empty(),
"background tasks still alive after stop()"
);
assert!(
node.file_transfer_state
.receiver_handle
.lock()
.await
.is_none(),
"FT receiver handle not cleared by stop()"
);
}
#[tokio::test]
async fn stopped_node_fails_raw_and_resolution_apis_fast() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-guards", ws_port).await;
node.stop().await;
assert!(matches!(
node.open_tcp("nobody", 4242).await.unwrap_err(),
NodeError::Stopped
));
assert!(matches!(
node.listen_tcp(4242).await.unwrap_err(),
NodeError::Stopped
));
assert!(matches!(
node.connect_quic("nobody", 4242).await.unwrap_err(),
NodeError::Stopped
));
assert!(matches!(
node.listen_quic(4242).await.unwrap_err(),
NodeError::Stopped
));
assert!(matches!(
node.bind_udp(4242).await.err(),
Some(NodeError::Stopped)
));
assert!(matches!(
node.ping("nobody").await.unwrap_err(),
NodeError::Stopped
));
assert!(matches!(
node.resolve_peer_id("nobody").await.unwrap_err(),
NodeError::Stopped
));
}
#[test]
fn bytes_payload_roundtrip() {
let data = vec![0u8, 159, 146, 150, 255];
let payload = Node::<MockNetworkProvider>::bytes_payload(&data);
assert_eq!(payload["encoding"], "base64");
let msg = NamespacedMessage {
from: "p".into(),
namespace: "ns".into(),
msg_type: "bytes".into(),
payload,
timestamp: None,
};
assert_eq!(msg.payload_bytes().unwrap(), data);
}
#[test]
fn payload_bytes_rejects_other_msg_types() {
let msg = NamespacedMessage {
from: "p".into(),
namespace: "ns".into(),
msg_type: "message".into(),
payload: json!({"data": "aGk="}),
timestamp: None,
};
assert!(msg.payload_bytes().is_none());
}
#[tokio::test]
async fn broadcast_json_reports_zero_peers() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-bj", ws_port).await;
let report = node.broadcast_json("ns", &json!({"a": 1})).await.unwrap();
assert_eq!(report.attempted, 0);
assert_eq!(report.queued, 0);
assert!(report.failed.is_empty());
}
#[tokio::test]
async fn subscribe_slow_path_sweeps_dead_namespaces() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-sweep", ws_port).await;
let rx = node.subscribe("dyn-a");
drop(rx);
let _rx_b = node.subscribe("dyn-b");
let filters = node.namespace_filters.read().unwrap();
assert!(!filters.contains_key("dyn-a"), "dead entry not swept");
assert!(filters.contains_key("dyn-b"));
}
#[tokio::test]
async fn router_prunes_namespace_after_receivers_drop() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-prune", ws_port).await;
let rx = node.subscribe("dyn-c");
drop(rx);
let envelope = Envelope::new("dyn-c", "message", json!({"x": 1})).with_timestamp();
let data = JsonCodec.encode(&envelope).unwrap();
node.session.test_inject_incoming("peer-x", data);
for _ in 0..50 {
{
let filters = node.namespace_filters.read().unwrap();
if !filters.contains_key("dyn-c") {
return;
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("dead namespace entry was not pruned by the router");
}
#[tokio::test]
async fn test_node_builder_creates_node() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let identity = node.local_info();
assert_eq!(identity.tailscale_id, "node-1");
assert_eq!(identity.device_id, "node-1");
assert!(identity.tailscale_hostname.contains("node-1"));
}
#[tokio::test]
async fn test_node_peers_from_network() {
let ws_port = random_port().await;
let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
let peers = node.peers().await;
assert!(peers.is_empty());
let peer = make_loopback_peer("peer-a");
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(50)).await;
let peers = node.peers().await;
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].tailscale_id, "peer-a");
assert!(
peers[0].device_id.is_none(),
"RFC 022: no ULID before identity"
);
assert!(peers[0].online);
assert!(!peers[0].ws_connected);
}
#[tokio::test]
#[allow(deprecated)] async fn test_node_send_to_unknown_peer_errors() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let result = node.send("nonexistent", "test", b"hello").await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("unknown peer") || err_str.contains("not found"),
"expected unknown peer error, got: {err_str}"
);
}
#[tokio::test]
async fn test_node_send_wraps_in_envelope() {
let codec = JsonCodec;
let data = b"hello world";
let envelope = Envelope::new("test-ns", "message", serde_json::Value::from(data.to_vec()))
.with_timestamp();
let encoded = codec.encode(&envelope).unwrap();
let decoded = codec.decode(&encoded).unwrap();
assert_eq!(decoded.namespace, "test-ns");
assert_eq!(decoded.msg_type, "message");
assert!(decoded.timestamp.is_some());
}
#[tokio::test]
async fn test_node_subscribe_filters_by_namespace() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let _rx_chat = node.subscribe("chat");
let _rx_ft = node.subscribe("ft");
let _rx_chat2 = node.subscribe("chat");
}
#[tokio::test]
#[allow(deprecated)] async fn test_node_broadcast() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
node.broadcast("test", b"hello everyone").await;
}
#[tokio::test]
async fn test_node_open_tcp_resolves_peer() {
let ws_port = random_port().await;
let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let tcp_port = listener.local_addr().unwrap().port();
let peer = make_loopback_peer("peer-tcp");
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(50)).await;
let accept_handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
stream
});
let stream = node.open_tcp("peer-tcp", tcp_port).await;
assert!(stream.is_ok(), "open_tcp failed: {:?}", stream.err());
let _ = accept_handle.await;
}
#[tokio::test]
async fn test_node_open_tcp_unknown_peer_errors() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let result = node.open_tcp("nonexistent", 8080).await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("not found"),
"expected peer not found error, got: {err_str}"
);
}
#[tokio::test]
async fn test_node_ping_resolves_peer() {
let ws_port = random_port().await;
let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
let result = node.ping("peer-ping").await;
assert!(result.is_err());
let peer = make_loopback_peer("peer-ping");
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(50)).await;
let result = node.ping("peer-ping").await;
assert!(result.is_ok());
assert_eq!(result.unwrap().latency, Duration::from_millis(1));
}
#[tokio::test]
async fn test_node_health() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let health = node.health().await;
assert!(health.healthy);
assert_eq!(health.state, "running");
}
#[tokio::test]
async fn test_node_connect_quic_unknown_peer_errors() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let result = node.connect_quic("peer", 4433).await;
assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
}
#[tokio::test]
async fn test_node_listen_tcp() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let listener = node.listen_tcp(0).await;
assert!(listener.is_ok(), "listen_tcp failed: {:?}", listener.err());
}
#[tokio::test]
async fn test_envelope_serialize_deserialize() {
let envelope = Envelope::new("chat", "message", json!({"text": "hello"})).with_timestamp();
let bytes = envelope.serialize().unwrap();
let decoded = Envelope::deserialize(&bytes).unwrap();
assert_eq!(decoded.namespace, "chat");
assert_eq!(decoded.msg_type, "message");
assert_eq!(decoded.payload["text"], "hello");
assert!(decoded.timestamp.is_some());
}
#[tokio::test]
async fn test_envelope_codec_json() {
let codec = JsonCodec;
let envelope = Envelope::new("ft", "offer", json!({"file": "test.bin"}));
let encoded = codec.encode(&envelope).unwrap();
let decoded = codec.decode(&encoded).unwrap();
assert_eq!(decoded.namespace, "ft");
assert_eq!(decoded.payload["file"], "test.bin");
}
#[tokio::test]
async fn test_envelope_unknown_fields_ignored() {
let json_bytes = br#"{
"namespace": "v2",
"msg_type": "new",
"payload": {},
"future_field": "ignored"
}"#;
let codec = JsonCodec;
let decoded = codec.decode(json_bytes).unwrap();
assert_eq!(decoded.namespace, "v2");
assert_eq!(decoded.msg_type, "new");
}
#[tokio::test]
#[allow(deprecated)] async fn test_node_send_and_receive_roundtrip() {
let port_a = random_port().await;
let port_b = random_port().await;
let (node_a, event_tx_a, _net_a) = make_test_node("node-a", port_a).await;
let (node_b, event_tx_b, _net_b) = make_test_node("node-b", port_b).await;
let peer_b = NetworkPeer {
id: "node-b".to_string(),
hostname: "truffle-test-node-b".to_string(),
ip: "127.0.0.1".parse().unwrap(),
online: true,
cur_addr: Some("127.0.0.1:41641".to_string()),
relay: None,
os: None,
last_seen: None,
key_expiry: None,
dns_name: None,
};
let peer_a = NetworkPeer {
id: "node-a".to_string(),
hostname: "truffle-test-node-a".to_string(),
ip: "127.0.0.1".parse().unwrap(),
online: true,
cur_addr: Some("127.0.0.1:41641".to_string()),
relay: None,
os: None,
last_seen: None,
key_expiry: None,
dns_name: None,
};
let _ = event_tx_a.send(NetworkPeerEvent::Joined(peer_b));
let _ = event_tx_b.send(NetworkPeerEvent::Joined(peer_a));
tokio::time::sleep(Duration::from_millis(100)).await;
let mut rx = node_b.subscribe("test");
let send_result = node_a.send("node-b", "test", b"hello from a").await;
if send_result.is_ok() {
let msg = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await;
if let Ok(Ok(msg)) = msg {
assert_eq!(msg.namespace, "test");
}
}
}
#[tokio::test]
#[allow(deprecated)] async fn test_node_stop_shuts_down_provider_and_is_idempotent() {
let ws_port = random_port().await;
let (node, event_tx, network) = make_test_node("node-1", ws_port).await;
let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-1")));
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(network.stop_call_count(), 0);
node.stop().await;
assert_eq!(
network.stop_call_count(),
1,
"stop() must shut down the provider"
);
node.stop().await;
assert_eq!(network.stop_call_count(), 1);
let err = node.send("peer-1", "test", b"hi").await.unwrap_err();
assert!(matches!(err, NodeError::Stopped));
}
#[tokio::test]
#[allow(deprecated)] async fn test_node_stop_closes_ws_connections() {
let port_a = random_port().await;
let (node_a, event_tx_a, _net_a) =
make_test_node_with_device_id("node-a", "dev-node-a", port_a).await;
let self_peer = NetworkPeer {
id: "node-a".to_string(),
hostname: "truffle-test-node-a".to_string(),
ip: "127.0.0.1".parse().unwrap(),
online: true,
cur_addr: Some("127.0.0.1:41641".to_string()),
relay: None,
os: None,
last_seen: None,
key_expiry: None,
dns_name: None,
};
let _ = event_tx_a.send(NetworkPeerEvent::Joined(self_peer));
tokio::time::sleep(Duration::from_millis(100)).await;
node_a
.send("node-a", "test", b"hello from a")
.await
.expect("loopback self-dial should establish a WS connection");
let peers = node_a.peers().await;
let entry = peers
.iter()
.find(|p| p.tailscale_id == "node-a")
.expect("self peer discovered");
assert!(
entry.ws_connected,
"send() should have established a WS connection"
);
node_a.stop().await;
let peers = node_a.peers().await;
let entry = peers
.iter()
.find(|p| p.tailscale_id == "node-a")
.expect("self peer still discovered");
assert!(
!entry.ws_connected,
"stop() must close and un-mark WS connections"
);
}
async fn inject_peer_with_identity(
node: &Node<MockNetworkProvider>,
event_tx: &broadcast::Sender<NetworkPeerEvent>,
tailscale_id: &str,
device_id: &str,
device_name: &str,
) {
let peer = make_loopback_peer(tailscale_id);
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(30)).await;
let identity = crate::session::PeerIdentity {
app_id: "test".into(),
device_id: device_id.into(),
device_name: device_name.into(),
os: "linux".into(),
tailscale_id: tailscale_id.into(),
};
assert!(
node.session
.test_stamp_identity(tailscale_id, identity)
.await,
"peer {tailscale_id} should exist in session registry before stamping identity"
);
}
#[tokio::test]
async fn test_resolve_peer_id_by_device_id() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
inject_peer_with_identity(
&node,
&event_tx,
"tailscale-abc",
"01HZZZZZZZZZZZZZZZZZZZZZZZ",
"Alice MacBook",
)
.await;
let resolved = node
.resolve_peer_id("01HZZZZZZZZZZZZZZZZZZZZZZZ")
.await
.unwrap();
assert_eq!(resolved, "01HZZZZZZZZZZZZZZZZZZZZZZZ");
}
#[tokio::test]
async fn test_resolve_peer_id_by_device_name() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
inject_peer_with_identity(
&node,
&event_tx,
"tailscale-abc",
"01HXYZXYZXYZXYZXYZXYZXYZXY",
"Bob's Mac",
)
.await;
let resolved = node.resolve_peer_id("Bob's Mac").await.unwrap();
assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
}
#[tokio::test]
async fn test_resolve_peer_id_by_device_id_prefix() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
inject_peer_with_identity(
&node,
&event_tx,
"tailscale-abc",
"01HXYZXYZXYZXYZXYZXYZXYZXY",
"laptop",
)
.await;
let resolved = node.resolve_peer_id("01HX").await.unwrap();
assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
}
#[tokio::test]
async fn test_resolve_peer_id_by_tailscale_id_legacy() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
inject_peer_with_identity(
&node,
&event_tx,
"tailscale-legacy",
"01HLEGACY0000000000000000X",
"legacy box",
)
.await;
let resolved = node.resolve_peer_id("tailscale-legacy").await.unwrap();
assert_eq!(resolved, "01HLEGACY0000000000000000X");
}
#[tokio::test]
async fn test_resolve_peer_id_unknown() {
let ws_port = random_port().await;
let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
let result = node.resolve_peer_id("nope").await;
assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
}
#[tokio::test]
async fn test_resolve_peer_by_bare_name_before_hello() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
let mut peer = make_loopback_peer("nodeid-9");
peer.hostname = "truffle-test-ec2-smoke".to_string();
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(node.resolve_peer("ec2-smoke").await.unwrap().id, "nodeid-9");
assert_eq!(node.resolve_peer("EC2 Smoke").await.unwrap().id, "nodeid-9");
assert!(node.resolve_peer("other-box").await.is_err());
}
#[tokio::test]
async fn test_peers_device_name_strips_hostname_before_hello() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
let mut peer = make_loopback_peer("nodeid-9");
peer.hostname = "truffle-test-ec2-smoke".to_string();
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(50)).await;
let peers = node.peers().await;
assert_eq!(peers.len(), 1);
assert!(peers[0].device_name.is_none());
assert_eq!(peers[0].display_name, "ec2-smoke");
assert_eq!(peers[0].hostname, "truffle-test-ec2-smoke");
assert!(peers[0].device_id.is_none());
}
#[tokio::test]
async fn test_resolve_peer_stale_ref_is_peer_gone() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-9")));
tokio::time::sleep(Duration::from_millis(50)).await;
let live_ref = node.peers().await[0].peer_ref.clone();
assert_eq!(node.resolve_peer(&live_ref).await.unwrap().id, "peer-9");
let _ = event_tx.send(NetworkPeerEvent::Left("peer-9".to_string()));
tokio::time::sleep(Duration::from_millis(50)).await;
let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-9")));
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(matches!(
node.resolve_peer(&live_ref).await,
Err(NodeError::PeerGone(_))
));
assert!(matches!(
node.resolve_peer("peer-9:99").await,
Err(NodeError::PeerGone(_))
));
assert!(matches!(
node.peer(&live_ref, Some(2_000)).await,
Err(NodeError::PeerGone(_))
));
}
#[tokio::test]
async fn test_resolve_peer_accepts_ip() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-a")));
tokio::time::sleep(Duration::from_millis(50)).await;
let resolved = node.resolve_peer("127.0.0.1").await.unwrap();
assert_eq!(resolved.id, "peer-a");
assert_eq!(node.resolve_peer_id("127.0.0.1").await.unwrap(), "peer-a");
}
#[tokio::test]
async fn test_listen_tcp_rejects_reserved_ports() {
let ws_port = random_port().await;
let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
let err = node.listen_tcp(9417).await.unwrap_err();
assert!(
matches!(err, NodeError::ReservedPort(9417)),
"expected ReservedPort(9417), got: {err}"
);
match node.listen_tcp(443).await {
Ok(listener) => assert_eq!(listener.port, 443),
Err(NodeError::ReservedPort(_)) => panic!("443 must not be reserved (RFC 023 D4)"),
Err(e) => assert!(
e.to_string().contains("RFC 023"),
"443 bind errors should hint at the sidecar upgrade, got: {e}"
),
}
}
#[tokio::test]
async fn test_listen_quic_rejects_reserved_and_ephemeral_ports() {
let ws_port = random_port().await;
let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
let err = node.listen_quic(9417).await.unwrap_err();
assert!(matches!(err, NodeError::ReservedPort(9417)));
let err = node.listen_quic(0).await.unwrap_err();
assert!(matches!(err, NodeError::NotImplemented(_)));
}
#[test]
fn test_builder_hostname_override_validation() {
assert!(NodeBuilder::default().hostname("dashboard").is_ok());
assert!(NodeBuilder::default().hostname("my-app-1").is_ok());
for bad in ["", "Dashboard", "has.dot", "-lead", "trail-"] {
assert!(
NodeBuilder::default().hostname(bad).is_err(),
"expected hostname {bad:?} to be rejected"
);
}
let too_long = "x".repeat(64);
assert!(NodeBuilder::default().hostname(too_long).is_err());
}
#[tokio::test]
async fn test_bind_udp_falls_back_to_direct_socket() {
let ws_port = random_port().await;
let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
let socket = node.bind_udp(0).await.unwrap();
let addr = socket.local_addr().unwrap();
assert_ne!(addr.port(), 0);
}
#[test]
fn test_raw_alpn_scoping() {
assert_eq!(
crate::transport::quic::raw_alpn("demo"),
b"truffle-raw.demo".to_vec()
);
assert_eq!(
crate::transport::quic::raw_alpn(""),
b"truffle-raw".to_vec()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_connect_quic_roundtrip_via_raw_listener() {
let ws_port = random_port().await;
let (client_node, event_tx, _net) = make_test_node("cli", ws_port).await;
let server_network = Arc::new(MockNetworkProvider::new("srv"));
let alpn = crate::transport::quic::raw_alpn("test");
let listener = crate::transport::quic::listen_raw(&server_network, 0, &alpn)
.await
.unwrap();
let port = listener.port();
assert_ne!(port, 0);
let echo_task = tokio::spawn(async move {
let conn = listener.accept().await.expect("no incoming connection");
let mut stream = conn
.accept_stream()
.await
.unwrap()
.expect("connection closed before stream");
let mut received = Vec::new();
while let Some(chunk) = stream.read(1024).await.unwrap() {
received.extend_from_slice(&chunk);
}
stream.write(&received).await.unwrap();
stream.finish();
tokio::time::sleep(Duration::from_millis(500)).await;
conn.close();
});
let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("srv-peer")));
tokio::time::sleep(Duration::from_millis(50)).await;
let conn = client_node.connect_quic("srv-peer", port).await.unwrap();
let mut stream = conn.open_stream().await.unwrap();
stream.write(b"hello quic").await.unwrap();
stream.finish();
let mut echoed = Vec::new();
while let Some(chunk) = stream.read(1024).await.unwrap() {
echoed.extend_from_slice(&chunk);
}
assert_eq!(echoed, b"hello quic");
echo_task.await.unwrap();
conn.close();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quic_raw_alpn_mismatch_rejected() {
let server_network = Arc::new(MockNetworkProvider::new("srv"));
let listener = crate::transport::quic::listen_raw(
&server_network,
0,
&crate::transport::quic::raw_alpn("app-a"),
)
.await
.unwrap();
let port = listener.port();
let client_network = Arc::new(MockNetworkProvider::new("cli"));
let result = crate::transport::quic::connect_raw(
&client_network,
"127.0.0.1",
port,
&crate::transport::quic::raw_alpn("app-b"),
)
.await;
assert!(
result.is_err(),
"cross-app QUIC connect must fail (ALPN mismatch)"
);
}
#[tokio::test]
async fn test_rfc022_node_peer_resolves_and_wait_miss_returns_none() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
assert!(node.peer("nope", None).await.unwrap().is_none());
let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-a")));
tokio::time::sleep(Duration::from_millis(50)).await;
let p = node.peer("peer-a", None).await.unwrap().expect("found");
assert_eq!(p.tailscale_id, "peer-a");
assert!(p.device_id.is_none());
let start = std::time::Instant::now();
let miss = node.peer("still-missing", Some(80)).await.unwrap();
assert!(miss.is_none());
assert!(start.elapsed() >= Duration::from_millis(70));
}
#[test]
fn test_rfc022_peer_projection_never_uses_tailscale_as_device_id() {
use crate::session::PeerState;
use std::net::Ipv4Addr;
let pre = PeerState {
id: "ts-abc".into(),
generation: 1,
name: "truffle-app-laptop".into(),
ip: Ipv4Addr::new(100, 64, 0, 1).into(),
online: true,
ws_connected: false,
connection_type: "direct".into(),
os: None,
last_seen: None,
identity: None,
identity_suppressed: false,
};
let p = Peer::from(pre);
assert!(p.device_id.is_none());
assert_eq!(p.tailscale_id, "ts-abc");
assert_eq!(p.peer_ref, "ts-abc:1");
assert_eq!(p.display_name, "laptop");
assert!(p
.device_id
.as_ref()
.map(|d| d.as_str() != p.tailscale_id)
.unwrap_or(true));
let post = PeerState {
id: "ts-abc".into(),
generation: 1,
name: "truffle-app-laptop".into(),
ip: Ipv4Addr::new(100, 64, 0, 1).into(),
online: true,
ws_connected: true,
connection_type: "direct".into(),
os: Some("darwin".into()),
last_seen: None,
identity: Some(crate::session::PeerIdentity {
app_id: "app".into(),
device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
device_name: "Alice's Mac".into(),
os: "darwin".into(),
tailscale_id: "ts-abc".into(),
}),
identity_suppressed: false,
};
let p = Peer::from(post);
assert_eq!(p.device_id.as_deref(), Some("01J4K9M2Z8AB3RNYQPW6H5TC0X"));
assert_ne!(p.device_id.as_deref().unwrap(), p.tailscale_id);
assert_eq!(p.display_name, "Alice's Mac");
let suppressed = PeerState {
id: "ts-xyz".into(),
generation: 1,
name: "truffle-app-other".into(),
ip: Ipv4Addr::new(100, 64, 0, 2).into(),
online: true,
ws_connected: true,
connection_type: "direct".into(),
os: None,
last_seen: None,
identity: Some(crate::session::PeerIdentity {
app_id: "app".into(),
device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
device_name: "Clone".into(),
os: "linux".into(),
tailscale_id: "ts-xyz".into(),
}),
identity_suppressed: true,
};
let p = Peer::from(suppressed);
assert!(
p.device_id.is_none(),
"suppressed claim must project null device_id"
);
}
}