Skip to main content

truffle_core/
node.rs

1//! Node API — the single public entry point for all truffle functionality.
2//!
3//! The [`Node`] struct wires together Layers 3-6 and exposes a clean ~12-method
4//! API that Layer 7 applications consume. Applications should **never** import
5//! from lower layers directly; everything they need is accessible through `Node`.
6//!
7//! # Quick start
8//!
9//! ```ignore
10//! use truffle_core::Node;
11//!
12//! let node = Node::builder()
13//!     .name("my-app")
14//!     .sidecar_path("/usr/local/bin/truffle-sidecar")
15//!     .build()
16//!     .await?;
17//!
18//! // Discover peers (Layer 3 — no transport needed)
19//! let peers = node.peers().await;
20//!
21//! // Send a namespaced message (Layer 6 envelope over Layer 4 WS).
22//! // String args remain queries; RFC 022 Phase B adds Peer handles.
23//! node.send(&peers[0].tailscale_id, "chat", b"hello!").await?;
24//!
25//! // Subscribe to a namespace
26//! let mut rx = node.subscribe("chat");
27//! let msg = rx.recv().await?;
28//!
29//! // Open a raw TCP stream (Layer 4 direct)
30//! let stream = node.open_tcp(&peers[0].tailscale_id, 8080).await?;
31//! ```
32
33use std::collections::HashMap;
34use std::net::IpAddr;
35use std::path::{Path, PathBuf};
36use std::sync::Arc;
37use std::time::Duration;
38// `std::sync::RwLock` is aliased to `StdRwLock` so the namespace_filters
39// lock — which is held only briefly for pure HashMap ops with no `.await`
40// points underneath — can stay sync. Keeping it sync lets
41// `Node::subscribe` remain non-async (required by the NAPI bridge, which
42// is called from Node.js's sync main thread with no tokio runtime in
43// scope) without the `try_write`-panic footgun that the previous
44// `tokio::sync::RwLock` implementation suffered from (see RFC 017 fix K).
45use std::sync::RwLock as StdRwLock;
46
47use tokio::net::TcpStream;
48use tokio::sync::broadcast;
49// The tokio RwLock is only used by the in-file test `MockNetworkProvider`
50// (see `#[cfg(test)] mod tests`); production code now uses the std
51// RwLock alias above for `namespace_filters`.
52#[cfg(test)]
53use tokio::sync::RwLock;
54
55use crate::envelope::codec::{EnvelopeCodec, JsonCodec};
56use crate::envelope::{Envelope, EnvelopeError};
57use crate::file_transfer::{self, FileTransferState};
58use crate::identity::{self, AppId, DeviceId, DeviceName};
59use crate::network::tailscale::{TailscaleConfig, TailscaleProvider};
60use crate::network::{DialOpts, HealthInfo, ListenOpts, NetworkProvider, NodeIdentity, PingResult};
61use crate::session::{PeerEvent, PeerRegistry, PeerState};
62use crate::transport::quic::{QuicConnection, QuicListener};
63use crate::transport::websocket::WebSocketTransport;
64use crate::transport::{DatagramSocket, RawListener, WsConfig};
65
66// ---------------------------------------------------------------------------
67// NamespacedMessage — public message type for subscribers
68// ---------------------------------------------------------------------------
69
70/// A message received on a specific namespace.
71///
72/// This is the public type that [`Node::subscribe`] delivers to application
73/// code. It contains the deserialized envelope fields plus the sender's peer ID.
74#[derive(Debug, Clone)]
75pub struct NamespacedMessage {
76    /// Stable node ID of the sender.
77    pub from: String,
78    /// Namespace the message was sent on.
79    pub namespace: String,
80    /// Application-defined message type within the namespace.
81    pub msg_type: String,
82    /// Opaque JSON payload.
83    pub payload: serde_json::Value,
84    /// Millisecond Unix timestamp from the sender, if set.
85    pub timestamp: Option<u64>,
86}
87
88impl NamespacedMessage {
89    /// Decode the payload of a [`send_bytes`](Node::send_bytes) /
90    /// [`broadcast_bytes`](Node::broadcast_bytes) message.
91    ///
92    /// Returns `None` unless `msg_type == "bytes"` with a valid base64
93    /// `data` field.
94    pub fn payload_bytes(&self) -> Option<Vec<u8>> {
95        if self.msg_type != "bytes" {
96            return None;
97        }
98        let s = self.payload.get("data")?.as_str()?;
99        use base64::Engine as _;
100        base64::engine::general_purpose::STANDARD.decode(s).ok()
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Peer — simplified view for application code
106// ---------------------------------------------------------------------------
107
108/// A peer as seen by application code (RFC 022 projection).
109///
110/// This is a simplified projection of the internal [`PeerState`] that hides
111/// session-layer internals. Networking still accepts string queries today;
112/// Phase B of RFC 022 will take handle-first parameters. Fields are already
113/// honest: `device_id` is never a Tailscale-id fallback.
114#[derive(Debug, Clone)]
115pub struct Peer {
116    /// Durable ULID once known and published; `None` until identity is learned
117    /// (or while suppressed under first-wins). Never equals `tailscale_id`.
118    pub device_id: Option<String>,
119    /// Human-readable device name from the hello identity block, if known.
120    pub device_name: Option<String>,
121    /// Best label for UI: identity name → hostname with `truffle-{appId}-`
122    /// stripped when possible → short tailscale id.
123    pub display_name: String,
124    /// Layer 3 Tailscale hostname (`truffle-{appId}-{slug}`).
125    pub hostname: String,
126    /// Tailscale stable node ID — routing key and advanced diagnostics.
127    pub tailscale_id: String,
128    /// Process-local ref `{tailscale_id}:{generation}` (RFC 022).
129    pub peer_ref: String,
130    /// Generation of this registry entry (bumped on re-join).
131    pub generation: u64,
132    /// Network IP address.
133    pub ip: IpAddr,
134    /// Whether the peer is online (from Layer 3).
135    pub online: bool,
136    /// Whether there is an active envelope-bus WebSocket connection.
137    pub ws_connected: bool,
138    /// Connection type description (e.g., `"direct"` or `"relay:ord"`).
139    pub connection_type: String,
140    /// Operating system, if known. Prefers the hello envelope's value
141    /// and falls back to Layer 3.
142    pub os: Option<String>,
143    /// Last time the peer was seen online (RFC 3339 string).
144    pub last_seen: Option<String>,
145}
146
147impl From<PeerState> for Peer {
148    fn from(s: PeerState) -> Self {
149        let (device_id, device_name, os_from_hello) = if s.identity_suppressed {
150            (None, None, None)
151        } else {
152            match s.identity.as_ref() {
153                Some(identity) => (
154                    Some(identity.device_id.clone()),
155                    Some(identity.device_name.clone()),
156                    Some(identity.os.clone()),
157                ),
158                None => (None, None, None),
159            }
160        };
161
162        // Invariant I1: published device_id must never equal the Tailscale id.
163        debug_assert!(
164            device_id
165                .as_ref()
166                .map(|d| d.as_str() != s.id.as_str())
167                .unwrap_or(true),
168            "RFC 022 I1 violated: device_id must not equal tailscale_id"
169        );
170
171        let display_name = device_name
172            .clone()
173            .filter(|n| !n.is_empty())
174            .unwrap_or_else(|| display_name_from_hostname(&s.name, &s.id));
175
176        let peer_ref = s.peer_ref();
177        let generation = s.generation;
178        let os = os_from_hello.or(s.os.clone());
179
180        Self {
181            device_id,
182            device_name,
183            display_name,
184            hostname: s.name,
185            tailscale_id: s.id,
186            peer_ref,
187            generation,
188            ip: s.ip,
189            online: s.online,
190            ws_connected: s.ws_connected,
191            connection_type: s.connection_type,
192            os,
193            last_seen: s.last_seen,
194        }
195    }
196}
197
198/// Derive a human-ish display name from a Tailscale hostname when identity
199/// is not yet known: strip a leading `truffle-…-` app prefix when present.
200fn display_name_from_hostname(hostname: &str, tailscale_id: &str) -> String {
201    const PREFIX: &str = "truffle-";
202    if let Some(rest) = hostname.strip_prefix(PREFIX) {
203        // rest = "{appId}-{slug…}"; drop the first label (appId).
204        if let Some((_, slug)) = rest.split_once('-') {
205            if !slug.is_empty() {
206                return slug.to_string();
207            }
208        }
209    }
210    if !hostname.is_empty() {
211        return hostname.to_string();
212    }
213    // Last resort: short tailscale id.
214    let short: String = tailscale_id.chars().take(8).collect();
215    if short.is_empty() {
216        "peer".to_string()
217    } else {
218        short
219    }
220}
221
222// ---------------------------------------------------------------------------
223// NodeError
224// ---------------------------------------------------------------------------
225
226/// Errors from the Node API.
227#[derive(Debug, thiserror::Error)]
228pub enum NodeError {
229    /// The requested peer is not known.
230    #[error("peer not found: {0}")]
231    PeerNotFound(String),
232
233    /// The query matched more than one peer (RFC 022 `mesh.peer`).
234    #[error("ambiguous peer query '{query}': {} candidates", candidates.len())]
235    AmbiguousPeer {
236        /// Original query string.
237        query: String,
238        /// Candidate display labels / routing keys for the UI.
239        candidates: Vec<String>,
240    },
241
242    /// The peer handle is no longer usable (left the mesh).
243    #[error("peer gone: {0}")]
244    PeerGone(String),
245
246    /// Failed to establish a connection.
247    #[error("connection failed: {0}")]
248    ConnectionFailed(String),
249
250    /// Failed to send a message.
251    #[error("send failed: {0}")]
252    SendFailed(String),
253
254    /// Envelope encoding/decoding error.
255    #[error("envelope error: {0}")]
256    Envelope(#[from] EnvelopeError),
257
258    /// Session layer error.
259    #[error("session error: {0}")]
260    Session(#[from] crate::session::SessionError),
261
262    /// Network layer error.
263    #[error("network error: {0}")]
264    Network(#[from] crate::network::NetworkError),
265
266    /// Transport layer error.
267    #[error("transport error: {0}")]
268    Transport(#[from] crate::transport::TransportError),
269
270    /// The requested feature is not yet implemented.
271    #[error("not implemented: {0}")]
272    NotImplemented(String),
273
274    /// The port is reserved by truffle's own listeners.
275    #[error("port {0} is reserved by truffle (the session WebSocket port)")]
276    ReservedPort(u16),
277
278    /// The node has been stopped.
279    #[error("node stopped")]
280    Stopped,
281
282    /// Builder configuration error.
283    #[error("build error: {0}")]
284    BuildError(String),
285
286    /// I/O error from the builder (state dir creation, device-id persistence).
287    #[error("io error: {0}")]
288    Io(#[from] std::io::Error),
289}
290
291// ---------------------------------------------------------------------------
292// Node
293// ---------------------------------------------------------------------------
294
295/// The main truffle node — single public entry point for all functionality.
296///
297/// Generic over `N: NetworkProvider` so that tests can inject a mock provider
298/// without Tailscale. In production, use the concrete type
299/// `Node<TailscaleProvider>` (created via [`NodeBuilder`]).
300///
301/// # Lifecycle
302///
303/// 1. Create via [`Node::builder()`] + `.build().await`
304/// 2. Use `peers()`, `send()`, `subscribe()`, `open_tcp()`, etc.
305/// 3. Call `stop()` to shut down
306pub struct Node<N: NetworkProvider + 'static> {
307    /// Layer 3 network provider.
308    pub(crate) network: Arc<N>,
309    /// Layer 5 session / peer registry.
310    session: Arc<PeerRegistry<N>>,
311    /// Layer 6 envelope codec.
312    codec: Arc<dyn EnvelopeCodec>,
313    /// Broadcast sender for all incoming namespaced messages.
314    /// Kept alive to prevent the channel from closing. The router task holds a clone.
315    #[allow(dead_code)]
316    incoming_tx: broadcast::Sender<NamespacedMessage>,
317    /// Per-namespace subscription channels.
318    ///
319    /// Guarded by a `std::sync::RwLock` (not tokio) because the lock is
320    /// held only for quick HashMap operations with no `.await` points
321    /// underneath, and the `subscribe()` API has to be callable from
322    /// synchronous contexts (the NAPI bridge, which runs on Node.js's
323    /// main thread with no tokio runtime in scope).
324    namespace_filters: Arc<StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>>,
325    /// File transfer subsystem state.
326    pub(crate) file_transfer_state: FileTransferState,
327    /// State directory for persistence (e.g., synced store backends). Empty
328    /// path when constructed via `from_parts` (tests); set by the builder.
329    state_dir: PathBuf,
330    /// The session WebSocket listen port (reserved against raw listeners
331    /// and proxy listeners alike — RFC 023 G4).
332    /// Defaults to 9417 in `from_parts`; set by the builder.
333    pub(crate) ws_port: u16,
334    /// Reverse proxy subsystem state.
335    pub(crate) proxy_state: Arc<crate::proxy::ProxyState>,
336    /// Set once [`stop`](Self::stop) has completed teardown. Makes further
337    /// `stop()` calls no-ops and causes the send paths to fail fast with
338    /// [`NodeError::Stopped`].
339    stopped: std::sync::atomic::AtomicBool,
340    /// Structured ownership of node-scoped background tasks.
341    pub(crate) tasks: NodeTasks,
342}
343
344/// Structured ownership of the node's background tasks.
345///
346/// Every node-scoped task (envelope router, file-transfer dispatch and
347/// per-transfer work) is spawned on the [`TaskTracker`] and watches the
348/// [`CancellationToken`]. [`Node::stop`] cancels the token, then waits for
349/// the tracker to drain with a bounded timeout, hard-aborting the retained
350/// long-lived handles as a last resort — so `stop()` deterministically
351/// means "all node work has stopped" and can never hang.
352pub(crate) struct NodeTasks {
353    /// Cooperative cancellation signal for every node-scoped task.
354    pub(crate) cancel: tokio_util::sync::CancellationToken,
355    /// Tracks all node-scoped tasks so stop() can await their completion.
356    pub(crate) tracker: tokio_util::task::TaskTracker,
357    /// Handles to long-lived tasks, retained for hard-abort if the
358    /// cooperative drain times out. Ephemeral tasks (per-transfer work) are
359    /// tracked but not retained here — holding a handle per completed
360    /// transfer would itself grow without bound.
361    long_lived: std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>,
362}
363
364impl NodeTasks {
365    fn new() -> Self {
366        Self {
367            cancel: tokio_util::sync::CancellationToken::new(),
368            tracker: tokio_util::task::TaskTracker::new(),
369            long_lived: std::sync::Mutex::new(Vec::new()),
370        }
371    }
372
373    /// Spawn a long-lived node task: tracked for the stop() drain AND
374    /// retained for hard-abort if that drain times out.
375    fn spawn_long_lived<F>(&self, fut: F)
376    where
377        F: std::future::Future<Output = ()> + Send + 'static,
378    {
379        let handle = self.tracker.spawn(fut);
380        self.long_lived.lock().unwrap().push(handle);
381    }
382
383    /// Abort every retained long-lived task (drain-timeout fallback).
384    fn abort_long_lived(&self) {
385        for h in self.long_lived.lock().unwrap().drain(..) {
386            h.abort();
387        }
388    }
389}
390
391impl<N: NetworkProvider + 'static> Node<N> {
392    /// Create a `Node` from pre-built components (used by builder and tests).
393    ///
394    /// This constructor wires together the layers and spawns the envelope
395    /// router task that reads from the session layer, deserializes envelopes,
396    /// and dispatches to namespace subscribers.
397    pub(crate) fn from_parts(
398        network: Arc<N>,
399        session: Arc<PeerRegistry<N>>,
400        codec: Arc<dyn EnvelopeCodec>,
401    ) -> Self {
402        let (incoming_tx, _) = broadcast::channel(1024);
403        let namespace_filters: Arc<
404            StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>,
405        > = Arc::new(StdRwLock::new(HashMap::new()));
406
407        let node = Self {
408            network,
409            session: session.clone(),
410            codec: codec.clone(),
411            incoming_tx: incoming_tx.clone(),
412            namespace_filters: namespace_filters.clone(),
413            file_transfer_state: FileTransferState::new(),
414            state_dir: PathBuf::new(),
415            ws_port: 9417,
416            proxy_state: Arc::new(crate::proxy::ProxyState::new()),
417            stopped: std::sync::atomic::AtomicBool::new(false),
418            tasks: NodeTasks::new(),
419        };
420
421        // Spawn the envelope router task.
422        node.spawn_envelope_router(session, codec, incoming_tx, namespace_filters);
423
424        // Forward runtime proxy-engine errors into the proxy event channel
425        // (RFC 023 G5). Providers without a proxy engine return None and
426        // the task is skipped entirely.
427        node.spawn_proxy_error_forwarder();
428
429        node
430    }
431
432    /// Spawn the node-scoped task that turns provider
433    /// [`ProxyRuntimeError`](crate::network::ProxyRuntimeError)s into
434    /// [`ProxyEvent::Error`](crate::proxy::ProxyEvent) broadcasts and
435    /// `ProxyStatus::Error` state.
436    fn spawn_proxy_error_forwarder(&self) {
437        let Some(mut rx) = self.network.proxy_runtime_errors() else {
438            return;
439        };
440        let proxy_state = self.proxy_state.clone();
441        let cancel = self.tasks.cancel.clone();
442        self.tasks.spawn_long_lived(async move {
443            loop {
444                tokio::select! {
445                    _ = cancel.cancelled() => {
446                        tracing::debug!("proxy error forwarder: cancelled by stop()");
447                        return;
448                    }
449                    event = rx.recv() => match event {
450                        Ok(e) => proxy_state.record_runtime_error(e.id, e.code, e.message),
451                        Err(broadcast::error::RecvError::Lagged(n)) => {
452                            tracing::warn!("proxy error forwarder lagged by {n} events");
453                        }
454                        Err(broadcast::error::RecvError::Closed) => return,
455                    }
456                }
457            }
458        });
459    }
460
461    /// Spawn a background task that reads incoming raw messages from the
462    /// session layer, deserializes them as envelopes, and routes them to
463    /// the global channel and per-namespace subscribers.
464    fn spawn_envelope_router(
465        &self,
466        session: Arc<PeerRegistry<N>>,
467        codec: Arc<dyn EnvelopeCodec>,
468        incoming_tx: broadcast::Sender<NamespacedMessage>,
469        namespace_filters: Arc<StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>>,
470    ) {
471        let mut rx = session.subscribe();
472        let cancel = self.tasks.cancel.clone();
473
474        self.tasks.spawn_long_lived(async move {
475            loop {
476                let recv = tokio::select! {
477                    _ = cancel.cancelled() => {
478                        tracing::debug!("envelope router: cancelled by stop()");
479                        break;
480                    }
481                    result = rx.recv() => result,
482                };
483                match recv {
484                    Ok(msg) => {
485                        if let Ok(envelope) = codec.decode(&msg.data) {
486                            let namespaced = NamespacedMessage {
487                                from: msg.from,
488                                namespace: envelope.namespace.clone(),
489                                msg_type: envelope.msg_type,
490                                payload: envelope.payload,
491                                timestamp: envelope.timestamp,
492                            };
493
494                            tracing::debug!(
495                                from = %namespaced.from,
496                                namespace = %namespaced.namespace,
497                                msg_type = %namespaced.msg_type,
498                                "envelope router: dispatching message"
499                            );
500
501                            // Send to global channel (best-effort).
502                            let _ = incoming_tx.send(namespaced.clone());
503
504                            // Route to namespace-specific subscriber if present.
505                            // Sync read on the std RwLock: the critical section
506                            // is a HashMap lookup with no `.await` points.
507                            // Poisoning is recovered (into_inner) instead of
508                            // panicking: the map stays structurally valid after
509                            // any panic, and a panic here would kill routing
510                            // for the lifetime of the node.
511                            let namespace = namespaced.namespace.clone();
512                            let dead_subscriber = {
513                                let filters = namespace_filters
514                                    .read()
515                                    .unwrap_or_else(std::sync::PoisonError::into_inner);
516                                match filters.get(&namespace) {
517                                    Some(tx) => {
518                                        let send_result = tx.send(namespaced);
519                                        tracing::debug!(
520                                            namespace = %namespace,
521                                            subscriber_count = tx.receiver_count(),
522                                            sent = send_result.is_ok(),
523                                            "envelope router: sent to namespace subscriber"
524                                        );
525                                        send_result.is_err()
526                                    }
527                                    None => {
528                                        tracing::debug!(
529                                            namespace = %namespace,
530                                            "envelope router: no subscriber for namespace"
531                                        );
532                                        false
533                                    }
534                                }
535                            };
536
537                            // A failed send means every receiver was dropped:
538                            // prune the entry so dynamic namespaces don't grow
539                            // the map forever. Re-checked under the write lock —
540                            // subscribe() adds receivers while holding the read
541                            // lock, so a zero count here cannot race with a new
542                            // subscriber.
543                            if dead_subscriber {
544                                let mut filters = namespace_filters
545                                    .write()
546                                    .unwrap_or_else(std::sync::PoisonError::into_inner);
547                                if filters
548                                    .get(&namespace)
549                                    .is_some_and(|tx| tx.receiver_count() == 0)
550                                {
551                                    filters.remove(&namespace);
552                                    tracing::debug!(
553                                        namespace = %namespace,
554                                        "envelope router: pruned dead namespace subscriber"
555                                    );
556                                }
557                            }
558                        } else {
559                            tracing::warn!(
560                                from = %msg.from,
561                                data_len = msg.data.len(),
562                                "node: failed to decode envelope from incoming message"
563                            );
564                        }
565                    }
566                    Err(broadcast::error::RecvError::Lagged(n)) => {
567                        tracing::warn!(
568                            missed = n,
569                            "node: envelope router lagged, missed {n} messages"
570                        );
571                        continue;
572                    }
573                    Err(broadcast::error::RecvError::Closed) => {
574                        tracing::debug!("node: session incoming channel closed, router exiting");
575                        break;
576                    }
577                }
578            }
579        });
580    }
581
582    // ── Builder ──────────────────────────────────────────────────────────
583
584    /// Create a new [`NodeBuilder`] for configuring and constructing a node.
585    pub fn builder() -> NodeBuilder {
586        NodeBuilder::default()
587    }
588
589    // ── File Transfer ────────────────────────────────────────────────────
590
591    /// Access the file transfer subsystem.
592    ///
593    /// Returns a [`FileTransfer`](file_transfer::FileTransfer) handle
594    /// that provides methods for sending, receiving, and pulling files.
595    pub fn file_transfer(&self) -> file_transfer::FileTransfer<'_, N> {
596        file_transfer::FileTransfer::new(self)
597    }
598
599    // ── Reverse Proxy ──────────────────────────────────────────────────
600
601    /// Access the reverse proxy subsystem.
602    ///
603    /// Returns a [`Proxy`](crate::proxy::Proxy) handle that provides
604    /// methods for adding, removing, and listing reverse proxies.
605    pub fn proxy(&self) -> crate::proxy::Proxy<'_, N> {
606        crate::proxy::Proxy::new(self)
607    }
608
609    /// Create a synchronized store for device-owned state.
610    ///
611    /// Returns an `Arc<SyncedStore<T>>` that syncs data across the mesh on
612    /// namespace `"ss:{store_id}"`. The caller owns the returned Arc;
613    /// the background sync task also holds one.
614    ///
615    /// Requires `self` to be wrapped in an `Arc` because the sync task
616    /// needs to outlive this call.
617    pub fn synced_store<T>(
618        self: &Arc<Self>,
619        store_id: &str,
620    ) -> Arc<crate::synced_store::SyncedStore<T>>
621    where
622        T: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
623    {
624        crate::synced_store::SyncedStore::new(self.clone(), store_id)
625    }
626
627    /// Create a synchronized store with a custom persistence backend.
628    ///
629    /// Same as [`synced_store`](Self::synced_store) but restores persisted
630    /// data on startup and writes through to the backend on every change.
631    pub fn synced_store_with_backend<T>(
632        self: &Arc<Self>,
633        store_id: &str,
634        backend: std::sync::Arc<dyn crate::synced_store::StoreBackend>,
635    ) -> Arc<crate::synced_store::SyncedStore<T>>
636    where
637        T: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
638    {
639        crate::synced_store::SyncedStore::new_with_backend(self.clone(), store_id, backend)
640    }
641
642    // ── State directory ───────────────────��────────────────────────────��
643
644    /// Set the state directory (called by the builder after construction).
645    pub(crate) fn with_state_dir(mut self, dir: PathBuf) -> Self {
646        self.state_dir = dir;
647        self
648    }
649
650    /// Record the session WebSocket port (called by the builder) so the
651    /// reserved-port guard tracks a customized `ws_port`.
652    pub(crate) fn with_ws_port(mut self, port: u16) -> Self {
653        self.ws_port = port;
654        self
655    }
656
657    /// The state directory for persistence backends.
658    ///
659    /// Returns an empty path for test nodes created via `from_parts`.
660    pub fn state_dir(&self) -> &Path {
661        &self.state_dir
662    }
663
664    // ── Lifecycle ────────────────────────────────────────────────────────
665
666    /// Stop the node and all underlying layers.
667    ///
668    /// Closes every active WebSocket connection (Layer 5) and shuts down the
669    /// network provider (Layer 3 — sidecar + bridge). After `stop()` returns,
670    /// [`send`](Self::send) and [`send_typed`](Self::send_typed) fail with
671    /// [`NodeError::Stopped`], and [`broadcast`](Self::broadcast) /
672    /// [`broadcast_typed`](Self::broadcast_typed) become no-ops.
673    ///
674    /// Idempotent: calling `stop()` more than once is safe — subsequent calls
675    /// return immediately without repeating teardown.
676    ///
677    /// When `stop()` returns, all node-scoped background work has stopped:
678    /// the envelope router, the file-transfer dispatch task, and in-flight
679    /// per-transfer tasks are cancelled and drained (with a bounded wait
680    /// that hard-aborts stragglers, so `stop()` cannot hang). Session and
681    /// provider background loops are torn down by their own layers.
682    pub async fn stop(&self) {
683        if self.stopped.swap(true, std::sync::atomic::Ordering::SeqCst) {
684            tracing::debug!("node: stop() called on already-stopped node");
685            return;
686        }
687        tracing::info!("node: stopping");
688
689        // 1. Cancel every node-scoped background task (envelope router,
690        //    file-transfer dispatch + per-transfer work). Cooperative: each
691        //    task exits at its next await point.
692        self.tasks.cancel.cancel();
693
694        // 2. Drop the file-transfer dispatch handle so a later
695        //    offer_channel() replace cannot race a half-stopped task.
696        if let Some(h) = self.file_transfer_state.receiver_handle.lock().await.take() {
697            h.abort();
698        }
699
700        // 3. Layer 5: close all active WebSocket connections and stop the
701        //    session's background loops (peer-event + accept).
702        self.session.shutdown().await;
703
704        // 4. Layer 3: shut down the network provider (sidecar + bridge).
705        if let Err(e) = self.network.stop().await {
706            tracing::warn!(error = %e, "node: network provider stop failed");
707        }
708
709        // 5. Deterministically drain node tasks. The token already fired,
710        //    so this normally completes immediately; the timeout guards
711        //    against a task stuck in an await that never resolves.
712        self.tasks.tracker.close();
713        let drain =
714            tokio::time::timeout(std::time::Duration::from_secs(5), self.tasks.tracker.wait());
715        if drain.await.is_err() {
716            tracing::warn!("node: background tasks did not drain in 5s; aborting stragglers");
717            self.tasks.abort_long_lived();
718            let _ =
719                tokio::time::timeout(std::time::Duration::from_secs(1), self.tasks.tracker.wait())
720                    .await;
721        }
722        tracing::info!("node: stopped");
723    }
724
725    // ── Identity ─────────────────────────────────────────────────────────
726
727    /// Return the local node's identity (stable ID, hostname, name).
728    pub fn local_info(&self) -> NodeIdentity {
729        self.network.local_identity()
730    }
731
732    // ── Discovery (from Layer 3, no transport needed) ────────────────────
733
734    /// Return all known peers.
735    ///
736    /// Includes peers that are online but not yet connected (no active WS).
737    /// This information comes from Layer 3 peer discovery.
738    pub async fn peers(&self) -> Vec<Peer> {
739        let app_id = self.network.local_identity().app_id;
740        self.session
741            .peers()
742            .await
743            .into_iter()
744            .map(|s| Self::project_peer(s, &app_id))
745            .collect()
746    }
747
748    /// Project a session [`PeerState`] to the public [`Peer`] view.
749    fn project_peer(s: PeerState, app_id: &str) -> Peer {
750        // RFC 022: `device_name` stays None until identity is known.
751        // Prefer a stripped hostname slug for `display_name` when we
752        // have no hello name yet (raw-transport / pre-identity peers).
753        let bare = s
754            .identity
755            .is_none()
756            .then(|| hostname_slug(&s.name, app_id).map(str::to_string))
757            .flatten();
758        let mut peer = Peer::from(s);
759        if let Some(bare) = bare {
760            peer.display_name = bare;
761        }
762        peer
763    }
764
765    /// Subscribe to peer change events (joined, left, connected, etc.).
766    pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
767        self.session.on_peer_change()
768    }
769
770    /// Resolve a query to a public [`Peer`] handle view (RFC 022).
771    ///
772    /// - Not found → `Ok(None)` after optional wait
773    /// - Ambiguous (multiple name / short-prefix hits) → [`NodeError::AmbiguousPeer`]
774    ///
775    /// `wait_ms`: when set, block until the query becomes resolvable or the
776    /// timeout elapses (then `Ok(None)`). Useful for rehydrating a saved ULID
777    /// whose owner has not completed hello yet.
778    pub async fn peer(&self, query: &str, wait_ms: Option<u64>) -> Result<Option<Peer>, NodeError> {
779        match self.resolve_peer(query).await {
780            Ok(state) => {
781                let app_id = self.network.local_identity().app_id;
782                return Ok(Some(Self::project_peer(state, &app_id)));
783            }
784            Err(NodeError::PeerNotFound(_)) => {}
785            Err(e) => return Err(e),
786        }
787
788        let Some(ms) = wait_ms.filter(|m| *m > 0) else {
789            return Ok(None);
790        };
791
792        let mut rx = self.session.on_peer_change();
793
794        // Re-check immediately after subscribing: an event landing between
795        // the initial miss above and the subscription would otherwise be
796        // lost, and the query would only re-resolve on the NEXT event (or
797        // never — burning the whole timeout on an already-resolvable peer).
798        match self.resolve_peer(query).await {
799            Ok(state) => {
800                let app_id = self.network.local_identity().app_id;
801                return Ok(Some(Self::project_peer(state, &app_id)));
802            }
803            Err(NodeError::PeerNotFound(_)) => {}
804            Err(e) => return Err(e),
805        }
806
807        let deadline = tokio::time::Instant::now() + Duration::from_millis(ms);
808        loop {
809            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
810            if remaining.is_zero() {
811                return Ok(None);
812            }
813            match tokio::time::timeout(remaining, rx.recv()).await {
814                Ok(Ok(_)) => match self.resolve_peer(query).await {
815                    Ok(state) => {
816                        let app_id = self.network.local_identity().app_id;
817                        return Ok(Some(Self::project_peer(state, &app_id)));
818                    }
819                    Err(NodeError::PeerNotFound(_)) => continue,
820                    Err(e) => return Err(e),
821                },
822                Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
823                Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return Ok(None),
824                Err(_) => return Ok(None), // timeout
825            }
826        }
827    }
828
829    /// Resolve any accepted peer identifier form to the peer's current
830    /// session state.
831    ///
832    /// The single resolution path shared by
833    /// [`resolve_peer_id`](Self::resolve_peer_id), [`ping`](Self::ping),
834    /// and the raw transport methods. Accepts the identifier forms
835    /// documented on `resolve_peer_id`, plus the peer's Tailscale IP.
836    pub(crate) async fn resolve_peer(&self, peer_ref: &str) -> Result<PeerState, NodeError> {
837        let peers = self.session.peers().await;
838        let app_id = self.network.local_identity().app_id;
839
840        // Exact matches that are always unique.
841        for p in &peers {
842            if let Some(uid) = p.published_device_id() {
843                if uid == peer_ref {
844                    return Ok(p.clone());
845                }
846            }
847            if p.id == peer_ref || p.name == peer_ref || p.ip.to_string() == peer_ref {
848                return Ok(p.clone());
849            }
850            if p.peer_ref() == peer_ref {
851                return Ok(p.clone());
852            }
853        }
854
855        // Device-name matches (published identity names) — may be ambiguous.
856        let name_hits: Vec<&PeerState> = peers
857            .iter()
858            .filter(|p| {
859                p.identity
860                    .as_ref()
861                    .map(|i| !p.identity_suppressed && i.device_name.eq_ignore_ascii_case(peer_ref))
862                    .unwrap_or(false)
863            })
864            .collect();
865        if name_hits.len() > 1 {
866            return Err(NodeError::AmbiguousPeer {
867                query: peer_ref.to_string(),
868                candidates: name_hits
869                    .iter()
870                    .map(|p| p.published_device_id().unwrap_or(p.id.as_str()).to_string())
871                    .collect(),
872            });
873        }
874        if let Some(hit) = name_hits.first() {
875            return Ok((*hit).clone());
876        }
877
878        // Bare device name via hostname slug (pre-identity peers).
879        let ref_slug = identity::slug(peer_ref, 255);
880        if !ref_slug.is_empty() {
881            let slug_hits: Vec<&PeerState> = peers
882                .iter()
883                .filter(|p| hostname_slug(&p.name, &app_id) == Some(ref_slug.as_str()))
884                .collect();
885            if slug_hits.len() > 1 {
886                return Err(NodeError::AmbiguousPeer {
887                    query: peer_ref.to_string(),
888                    candidates: slug_hits.iter().map(|p| p.id.clone()).collect(),
889                });
890            }
891            if let Some(hit) = slug_hits.first() {
892                return Ok((*hit).clone());
893            }
894        }
895
896        // Prefix match on published device_id (require ≥4 chars, unique).
897        if peer_ref.len() >= 4 {
898            let hits: Vec<&PeerState> = peers
899                .iter()
900                .filter(|p| {
901                    p.published_device_id()
902                        .map(|uid| uid.starts_with(peer_ref))
903                        .unwrap_or(false)
904                })
905                .collect();
906            if hits.len() > 1 {
907                return Err(NodeError::AmbiguousPeer {
908                    query: peer_ref.to_string(),
909                    candidates: hits
910                        .iter()
911                        .filter_map(|p| p.published_device_id().map(|s| s.to_string()))
912                        .collect(),
913                });
914            }
915            if let Some(hit) = hits.first() {
916                return Ok((*hit).clone());
917            }
918        }
919
920        // A well-formed peer ref that resolved nothing above refers to a
921        // departed or superseded generation (live refs exact-matched
922        // `p.peer_ref()` earlier) — surface PeerGone, not PeerNotFound, so
923        // stale handles fail loudly instead of looking like typos
924        // (RFC 022 I5). Checked last so colon-containing names still match.
925        if crate::session::parse_peer_ref(peer_ref).is_some() {
926            return Err(NodeError::PeerGone(peer_ref.to_string()));
927        }
928
929        Err(NodeError::PeerNotFound(peer_ref.to_string()))
930    }
931
932    /// Resolve a peer identifier to the peer's Tailscale IP address.
933    ///
934    /// Accepts the same identifier forms as
935    /// [`resolve_peer_id`](Self::resolve_peer_id). Used by the FFI layers
936    /// to address datagram sends by peer name.
937    pub async fn resolve_peer_ip(&self, peer_ref: &str) -> Result<IpAddr, NodeError> {
938        self.ensure_not_stopped()?;
939        Ok(self.resolve_peer(peer_ref).await?.ip)
940    }
941
942    /// Resolve a peer query to a string safe to pass to [`send`](Self::send)
943    /// and other peer-addressed methods.
944    ///
945    /// Prefer the published durable ULID when known and not suppressed;
946    /// otherwise return the Tailscale stable id (routing key). This is the
947    /// legacy string path — RFC 022 Phase B replaces it with `peer()` handles.
948    ///
949    /// Accepts any of:
950    /// - the stable `device_id` (full ULID)
951    /// - a unique prefix of the `device_id` (at least 4 characters; must
952    ///   match exactly one known peer)
953    /// - the human-readable `device_name` from the hello
954    /// - the bare device name of a peer that has not helloed yet (matched
955    ///   via the `truffle-{app_id}-{slug}` hostname convention)
956    /// - the Layer 3 Tailscale hostname (the sanitised slug)
957    /// - the Tailscale stable ID
958    /// - the Tailscale IP address (e.g. `100.x.x.x`)
959    pub async fn resolve_peer_id(&self, peer_id: &str) -> Result<String, NodeError> {
960        self.ensure_not_stopped()?;
961        let p = self.resolve_peer(peer_id).await?;
962        Ok(p.published_device_id()
963            .map(|s| s.to_string())
964            .unwrap_or_else(|| p.id.clone()))
965    }
966
967    // ── Diagnostics ──────────────────────────────────────────────────────
968
969    /// Ping a peer via the network layer.
970    ///
971    /// Resolves the peer ID to an IP address and pings via Layer 3. Accepts
972    /// the same identifier forms as [`resolve_peer_id`](Self::resolve_peer_id).
973    pub async fn ping(&self, peer_id: &str) -> Result<PingResult, NodeError> {
974        self.ensure_not_stopped()?;
975        let peer = self.resolve_peer(peer_id).await?;
976        let addr = peer.ip.to_string();
977        self.network.ping(&addr).await.map_err(NodeError::Network)
978    }
979
980    /// Tailnet identity (WhoIs) of the node that owns an address.
981    ///
982    /// Accepts a raw tailnet IP or `ip:port` (e.g. a QUIC connection's
983    /// `remote_address()` verbatim — the port is ignored), or any
984    /// [`resolve_peer_id`](Self::resolve_peer_id) identifier form for mesh
985    /// peers. Unlike [`peers`](Self::peers), a raw address reaches ANY
986    /// tailnet device — other apps' truffle nodes, plain machines, tagged
987    /// nodes — and the answer carries user identity (login, display name,
988    /// profile pic) that [`Peer`] deliberately does not.
989    ///
990    /// `Ok(None)` means the tailnet has no identity for the address: the
991    /// caller is anonymous — absent, not fabricated.
992    pub async fn whois(
993        &self,
994        addr: &str,
995    ) -> Result<Option<crate::network::TailscalePeerIdentity>, NodeError> {
996        self.ensure_not_stopped()?;
997        // Canonicalize parsed addresses (`ip.to_string()`, not the caller's
998        // spelling): the sidecar echoes the sent string verbatim for
999        // correlation, and a canonical form keeps that byte-exact even for
1000        // exotic-but-valid IPv6 spellings.
1001        let target = if let Ok(ip) = addr.parse::<std::net::IpAddr>() {
1002            ip.to_string()
1003        } else if let Ok(sock) = addr.parse::<std::net::SocketAddr>() {
1004            sock.ip().to_string()
1005        } else {
1006            self.resolve_peer(addr).await?.ip.to_string()
1007        };
1008        self.network
1009            .whois(&target)
1010            .await
1011            .map_err(NodeError::Network)
1012    }
1013
1014    /// Return health information from the network layer.
1015    pub async fn health(&self) -> HealthInfo {
1016        self.network.health().await
1017    }
1018
1019    // ── Messaging (Layer 6 envelope over Layer 4 WS) ─────────────────────
1020
1021    /// Return [`NodeError::Stopped`] if [`stop`](Self::stop) has already run.
1022    ///
1023    /// Used by the send paths to fail fast instead of attempting a doomed
1024    /// session send after teardown.
1025    fn ensure_not_stopped(&self) -> Result<(), NodeError> {
1026        if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
1027            return Err(NodeError::Stopped);
1028        }
1029        Ok(())
1030    }
1031
1032    /// Map session send errors: a stale peer-ref selector surfaces as
1033    /// [`NodeError::PeerGone`] (RFC 022 I5); everything else wraps as-is.
1034    fn map_session_send_err(e: crate::session::SessionError) -> NodeError {
1035        match e {
1036            crate::session::SessionError::PeerGone(r) => NodeError::PeerGone(r),
1037            e => NodeError::Session(e),
1038        }
1039    }
1040
1041    /// Send a namespaced message to a specific peer.
1042    ///
1043    /// **Deprecated**: the wire representation depends on the *contents* of
1044    /// `data` — bytes that parse as UTF-8 JSON are sent as that JSON value
1045    /// (`b"123"` → number, `b"null"` → null), anything else becomes a JSON
1046    /// array of byte values. Use [`send_json`](Self::send_json) for
1047    /// structured payloads or [`send_bytes`](Self::send_bytes) for opaque
1048    /// binary data instead.
1049    ///
1050    /// Returns [`NodeError::Stopped`] if [`stop`](Self::stop) has been called.
1051    #[deprecated(
1052        since = "0.7.0",
1053        note = "wire type depends on data contents; use send_json or send_bytes"
1054    )]
1055    pub async fn send(&self, peer_id: &str, namespace: &str, data: &[u8]) -> Result<(), NodeError> {
1056        self.ensure_not_stopped()?;
1057        // Legacy content sniffing, kept for compatibility: if the data is
1058        // valid UTF-8 JSON, parse it into a proper JSON value so the receiver
1059        // gets a structured object rather than an array of byte values.
1060        let payload = std::str::from_utf8(data)
1061            .ok()
1062            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
1063            .unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
1064
1065        let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
1066
1067        let encoded = self.codec.encode(&envelope)?;
1068        self.session
1069            .send(peer_id, &encoded)
1070            .await
1071            .map_err(Self::map_session_send_err)?;
1072        Ok(())
1073    }
1074
1075    /// Send a JSON payload to a specific peer.
1076    ///
1077    /// The payload is wrapped in a Layer 6 [`Envelope`] with the given
1078    /// namespace and a `"message"` type — subscribers observe it unchanged
1079    /// as [`NamespacedMessage::payload`]. If no WebSocket connection
1080    /// exists, one is lazily established.
1081    pub async fn send_json(
1082        &self,
1083        peer_id: &str,
1084        namespace: &str,
1085        payload: &serde_json::Value,
1086    ) -> Result<(), NodeError> {
1087        self.send_typed(peer_id, namespace, "message", payload)
1088            .await
1089    }
1090
1091    /// Send opaque binary data to a specific peer.
1092    ///
1093    /// The bytes travel base64-encoded in a `"bytes"`-typed envelope with
1094    /// payload shape `{"encoding":"base64","data":"…"}`; receivers decode
1095    /// with [`NamespacedMessage::payload_bytes`]. Unlike the deprecated
1096    /// [`send`](Self::send), the wire representation never depends on the
1097    /// data's contents.
1098    pub async fn send_bytes(
1099        &self,
1100        peer_id: &str,
1101        namespace: &str,
1102        data: &[u8],
1103    ) -> Result<(), NodeError> {
1104        let payload = Self::bytes_payload(data);
1105        self.send_typed(peer_id, namespace, "bytes", &payload).await
1106    }
1107
1108    /// Encode opaque bytes as the `"bytes"` envelope payload.
1109    fn bytes_payload(data: &[u8]) -> serde_json::Value {
1110        use base64::Engine as _;
1111        serde_json::json!({
1112            "encoding": "base64",
1113            "data": base64::engine::general_purpose::STANDARD.encode(data),
1114        })
1115    }
1116
1117    /// Send a namespaced message with an explicit `msg_type` and JSON payload.
1118    ///
1119    /// Unlike [`send`](Self::send), this method takes a pre-built
1120    /// [`serde_json::Value`] payload and a caller-chosen `msg_type` instead
1121    /// of raw bytes with a hardcoded `"message"` type. Used by subsystems
1122    /// (file transfer, synced store, request/reply) that define their own
1123    /// wire protocol message types.
1124    pub async fn send_typed(
1125        &self,
1126        peer_id: &str,
1127        namespace: &str,
1128        msg_type: &str,
1129        payload: &serde_json::Value,
1130    ) -> Result<(), NodeError> {
1131        self.ensure_not_stopped()?;
1132        let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
1133        let encoded = self.codec.encode(&envelope)?;
1134        self.session
1135            .send(peer_id, &encoded)
1136            .await
1137            .map_err(Self::map_session_send_err)?;
1138        Ok(())
1139    }
1140
1141    /// Broadcast a namespaced message with an explicit `msg_type` and JSON
1142    /// payload to all connected peers.
1143    pub async fn broadcast_typed(
1144        &self,
1145        namespace: &str,
1146        msg_type: &str,
1147        payload: &serde_json::Value,
1148    ) {
1149        if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
1150            tracing::debug!("node: broadcast after stop ignored");
1151            return;
1152        }
1153        let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
1154        match self.codec.encode(&envelope) {
1155            Ok(encoded) => {
1156                self.session.broadcast(&encoded).await;
1157            }
1158            Err(e) => {
1159                tracing::error!("node: failed to encode broadcast envelope: {e}");
1160            }
1161        }
1162    }
1163
1164    /// Broadcast a namespaced message to all connected peers.
1165    ///
1166    /// **Deprecated**: the wire representation depends on the contents of
1167    /// `data` (see [`send`](Self::send)), and delivery failures are
1168    /// silently discarded. Use [`broadcast_json`](Self::broadcast_json) or
1169    /// [`broadcast_bytes`](Self::broadcast_bytes), which return a
1170    /// [`BroadcastReport`](crate::session::BroadcastReport).
1171    #[deprecated(
1172        since = "0.7.0",
1173        note = "wire type depends on data contents and failures are hidden; \
1174                use broadcast_json or broadcast_bytes"
1175    )]
1176    pub async fn broadcast(&self, namespace: &str, data: &[u8]) {
1177        if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
1178            tracing::debug!("node: broadcast after stop ignored");
1179            return;
1180        }
1181        // Legacy content sniffing, kept for compatibility (see send()).
1182        let payload = std::str::from_utf8(data)
1183            .ok()
1184            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
1185            .unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
1186
1187        let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
1188
1189        match self.codec.encode(&envelope) {
1190            Ok(encoded) => {
1191                self.session.broadcast(&encoded).await;
1192            }
1193            Err(e) => {
1194                tracing::error!("node: failed to encode broadcast envelope: {e}");
1195            }
1196        }
1197    }
1198
1199    /// Broadcast a JSON payload to all connected peers.
1200    ///
1201    /// Only peers with an active WebSocket connection receive the message —
1202    /// no lazy connections are established. Returns a
1203    /// [`BroadcastReport`](crate::session::BroadcastReport): "queued" means
1204    /// handed to the peer's connection task, not confirmed delivery.
1205    pub async fn broadcast_json(
1206        &self,
1207        namespace: &str,
1208        payload: &serde_json::Value,
1209    ) -> Result<crate::session::BroadcastReport, NodeError> {
1210        self.broadcast_reported(namespace, "message", payload).await
1211    }
1212
1213    /// Broadcast opaque binary data to all connected peers.
1214    ///
1215    /// Same wire shape as [`send_bytes`](Self::send_bytes); same report
1216    /// semantics as [`broadcast_json`](Self::broadcast_json).
1217    pub async fn broadcast_bytes(
1218        &self,
1219        namespace: &str,
1220        data: &[u8],
1221    ) -> Result<crate::session::BroadcastReport, NodeError> {
1222        let payload = Self::bytes_payload(data);
1223        self.broadcast_reported(namespace, "bytes", &payload).await
1224    }
1225
1226    /// Shared broadcast implementation: encode once, report the outcome.
1227    /// Unlike the deprecated fire-and-forget path, encode failures and
1228    /// stopped-node calls surface as errors.
1229    async fn broadcast_reported(
1230        &self,
1231        namespace: &str,
1232        msg_type: &str,
1233        payload: &serde_json::Value,
1234    ) -> Result<crate::session::BroadcastReport, NodeError> {
1235        self.ensure_not_stopped()?;
1236        let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
1237        let encoded = self.codec.encode(&envelope)?;
1238        Ok(self.session.broadcast(&encoded).await)
1239    }
1240
1241    /// Subscribe to messages in a specific namespace.
1242    ///
1243    /// Returns a broadcast receiver that yields [`NamespacedMessage`]s
1244    /// matching the given namespace. Multiple subscribers to the same
1245    /// namespace share the same underlying channel.
1246    pub fn subscribe(&self, namespace: &str) -> broadcast::Receiver<NamespacedMessage> {
1247        // Fast path: check if subscriber already exists (read lock).
1248        // Poisoning is recovered (into_inner), not propagated — see the
1249        // envelope router for the rationale.
1250        {
1251            let filters = self
1252                .namespace_filters
1253                .read()
1254                .unwrap_or_else(std::sync::PoisonError::into_inner);
1255            if let Some(tx) = filters.get(namespace) {
1256                return tx.subscribe();
1257            }
1258        }
1259
1260        // Slow path: create a new channel for this namespace (write lock).
1261        let mut filters = self
1262            .namespace_filters
1263            .write()
1264            .unwrap_or_else(std::sync::PoisonError::into_inner);
1265        // Double-check after acquiring write lock.
1266        if let Some(tx) = filters.get(namespace) {
1267            return tx.subscribe();
1268        }
1269        // Opportunistic sweep: drop entries whose receivers are all gone.
1270        // The router prunes on send failure, but only for namespaces that
1271        // still receive traffic — this catches the silent ones.
1272        filters.retain(|_, tx| tx.receiver_count() > 0);
1273        let (tx, rx) = broadcast::channel(256);
1274        filters.insert(namespace.to_string(), tx);
1275        rx
1276    }
1277
1278    // ── Raw streams (Layer 4 direct) ─────────────────────────────────────
1279
1280    /// Open a raw TCP stream to a peer on the given port.
1281    ///
1282    /// Resolves the peer ID to an IP address via the session's peer list,
1283    /// then dials via the network layer. Accepts the same identifier
1284    /// forms as [`resolve_peer_id`](Self::resolve_peer_id). Returns a
1285    /// plain `TcpStream` for byte-oriented I/O.
1286    ///
1287    /// The stream is raw even on port 443 — the sidecar's legacy auto-TLS
1288    /// wrap for 443 dials is disabled on this path.
1289    pub async fn open_tcp(&self, peer_id: &str, port: u16) -> Result<TcpStream, NodeError> {
1290        self.ensure_not_stopped()?;
1291        let peer = self.resolve_peer(peer_id).await?;
1292        let addr = peer.ip.to_string();
1293        self.network
1294            .dial_tcp_opts(&addr, port, DialOpts { tls: Some(false) })
1295            .await
1296            .map_err(|e| NodeError::ConnectionFailed(e.to_string()))
1297    }
1298
1299    /// Listen for incoming TCP connections on a port.
1300    ///
1301    /// Returns a [`RawListener`] that yields raw `TcpStream`s. The caller
1302    /// is responsible for accepting connections in a loop. Port 0 binds an
1303    /// ephemeral port (advertise the resolved `RawListener::port` in-band,
1304    /// like the file transfer subsystem does). The session WebSocket port
1305    /// (default 9417) is reserved.
1306    pub async fn listen_tcp(&self, port: u16) -> Result<RawListener, NodeError> {
1307        self.listen_tcp_opts(port, ListenOpts::default()).await
1308    }
1309
1310    /// As [`listen_tcp`](Self::listen_tcp), with options (RFC 023 §7.1).
1311    ///
1312    /// `tls: true` terminates TLS in the sidecar with automatic MagicDNS
1313    /// certificates (requires MagicDNS + HTTPS enabled on the tailnet);
1314    /// accepted streams then carry plaintext HTTP over the loopback bridge.
1315    pub async fn listen_tcp_opts(
1316        &self,
1317        port: u16,
1318        opts: ListenOpts,
1319    ) -> Result<RawListener, NodeError> {
1320        self.ensure_not_stopped()?;
1321        use crate::transport::tcp::TcpTransport;
1322
1323        ensure_port_unreserved(port, self.ws_port)?;
1324        let tcp = TcpTransport::new(self.network.clone());
1325        tcp.listen_opts(port, opts).await.map_err(|e| {
1326            // RFC 023 unreserved 443, but sidecars predating it still bind a
1327            // legacy TLS listener there — a double-bind here is almost
1328            // always that, so say so instead of a bare LISTEN_ERROR.
1329            if port == 443 {
1330                NodeError::ConnectionFailed(format!(
1331                    "{e} (port 443 requires a sidecar built with RFC 023 — older sidecars bind \
1332                     a legacy TLS listener on 443; upgrade the sidecar binary)"
1333                ))
1334            } else {
1335                NodeError::Transport(e)
1336            }
1337        })
1338    }
1339
1340    /// Stop listening on a previously opened TCP port.
1341    ///
1342    /// Dropping the [`RawListener`] alone stops local delivery but leaves
1343    /// the tsnet port bound in the sidecar; this releases it.
1344    pub async fn unlisten_tcp(&self, port: u16) -> Result<(), NodeError> {
1345        self.ensure_not_stopped()?;
1346        self.network
1347            .unlisten_tcp(port)
1348            .await
1349            .map_err(NodeError::Network)
1350    }
1351
1352    /// Open a raw QUIC connection to a peer on the given port.
1353    ///
1354    /// The connection carries multiple concurrent bidirectional byte
1355    /// streams ([`QuicConnection::open_stream`]) with no head-of-line
1356    /// blocking between them. App scoping is enforced at the TLS layer via
1357    /// ALPN (`truffle-raw.{app_id}`) — peers from a different app fail the
1358    /// handshake. Accepts the same identifier forms as
1359    /// [`resolve_peer_id`](Self::resolve_peer_id).
1360    pub async fn connect_quic(
1361        &self,
1362        peer_id: &str,
1363        port: u16,
1364    ) -> Result<QuicConnection, NodeError> {
1365        self.ensure_not_stopped()?;
1366        let peer = self.resolve_peer(peer_id).await?;
1367        let alpn = crate::transport::quic::raw_alpn(&self.network.local_identity().app_id);
1368        crate::transport::quic::connect_raw(&self.network, &peer.ip.to_string(), port, &alpn)
1369            .await
1370            .map_err(NodeError::Transport)
1371    }
1372
1373    /// Listen for raw QUIC connections on a port.
1374    ///
1375    /// Returns a [`QuicListener`] that yields [`QuicConnection`]s. Only
1376    /// same-app peers can complete the handshake (ALPN scoping). The
1377    /// session WebSocket port (default 9417) is reserved, and port 0 is
1378    /// not yet supported over the tsnet relay (the relay cannot report the
1379    /// actual ephemeral port back).
1380    pub async fn listen_quic(&self, port: u16) -> Result<QuicListener, NodeError> {
1381        self.ensure_not_stopped()?;
1382        ensure_port_unreserved(port, self.ws_port)?;
1383        if port == 0 {
1384            return Err(NodeError::NotImplemented(
1385                "ephemeral (port 0) QUIC listeners are not supported over the tsnet relay yet — choose an explicit port"
1386                    .to_string(),
1387            ));
1388        }
1389        let alpn = crate::transport::quic::raw_alpn(&self.network.local_identity().app_id);
1390        crate::transport::quic::listen_raw(&self.network, port, &alpn)
1391            .await
1392            .map_err(NodeError::Transport)
1393    }
1394
1395    /// Bind a UDP datagram socket on a port.
1396    ///
1397    /// Datagrams are relayed through the network provider (tsnet) with
1398    /// boundaries preserved; the transport falls back to a direct host
1399    /// socket only when the provider has no UDP support (tests). Returns a
1400    /// [`DatagramSocket`] supporting `send_to` / `recv_from` with tailnet
1401    /// addresses. IPv4 (`100.x`) peers only; keep payloads ≤ ~1200 bytes
1402    /// to stay under the tailnet MTU. Port 0 binds an ephemeral relay
1403    /// port — suitable for client-style sockets that send first.
1404    pub async fn bind_udp(&self, port: u16) -> Result<DatagramSocket, NodeError> {
1405        self.ensure_not_stopped()?;
1406        use crate::transport::udp::{UdpConfig, UdpTransport};
1407        use crate::transport::DatagramTransport;
1408
1409        let udp = UdpTransport::new(self.network.clone(), UdpConfig::default());
1410        udp.bind(port).await.map_err(NodeError::Transport)
1411    }
1412}
1413
1414impl<N: NetworkProvider + 'static> Drop for Node<N> {
1415    fn drop(&mut self) {
1416        // Insurance for nodes dropped without stop(): cancelling is cheap,
1417        // synchronous, and lets background tasks exit instead of idling on
1418        // channels that may never close.
1419        self.tasks.cancel.cancel();
1420    }
1421}
1422
1423/// The bare device-name slug from a Layer 3 hostname, when it follows this
1424/// app's `truffle-{app_id}-{slug}` convention (RFC 017). `None` otherwise.
1425fn hostname_slug<'a>(hostname: &'a str, app_id: &str) -> Option<&'a str> {
1426    hostname
1427        .strip_prefix("truffle-")?
1428        .strip_prefix(app_id)?
1429        .strip_prefix('-')
1430}
1431
1432/// A valid single DNS label: 1–63 chars of `[a-z0-9-]`, lowercase, no
1433/// leading/trailing hyphen, no dots (tsnet takes a bare hostname, not an
1434/// FQDN). Used by [`NodeBuilder::hostname`].
1435fn validate_hostname_label(s: &str) -> Result<(), String> {
1436    let ok_len = (1..=63).contains(&s.len());
1437    let ok_chars = s
1438        .chars()
1439        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
1440    let ok_edges = !s.starts_with('-') && !s.ends_with('-');
1441    if ok_len && ok_chars && ok_edges {
1442        Ok(())
1443    } else {
1444        Err(format!(
1445            "invalid hostname {s:?}: must be a single lowercase DNS label \
1446             (1-63 chars of [a-z0-9-], no leading/trailing hyphen, no dots)"
1447        ))
1448    }
1449}
1450
1451/// Reject ports reserved by truffle's own listeners: the node's configured
1452/// session WebSocket port (default 9417). Port 443 is deliberately NOT
1453/// reserved anymore — RFC 023 removed the sidecar's legacy TLS listener so
1454/// users can serve HTTPS on the default port.
1455pub(crate) fn ensure_port_unreserved(port: u16, ws_port: u16) -> Result<(), NodeError> {
1456    if port == ws_port {
1457        Err(NodeError::ReservedPort(port))
1458    } else {
1459        Ok(())
1460    }
1461}
1462
1463// ---------------------------------------------------------------------------
1464// NodeBuilder
1465// ---------------------------------------------------------------------------
1466
1467/// Builder for constructing a [`Node<TailscaleProvider>`].
1468///
1469/// Configures the Tailscale sidecar, RFC 017 identity, and transport
1470/// parameters before wiring all layers together.
1471///
1472/// # Example
1473///
1474/// ```ignore
1475/// let node = Node::builder()
1476///     .app_id("playground")?
1477///     .device_name("alice-mbp")
1478///     .sidecar_path("/opt/truffle/sidecar")
1479///     .ws_port(9417)
1480///     .build()
1481///     .await?;
1482/// ```
1483#[derive(Clone)]
1484pub struct NodeBuilder {
1485    app_id: Option<AppId>,
1486    device_name: Option<DeviceName>,
1487    device_id: Option<DeviceId>,
1488    /// RFC 023 §6.4: explicit Tailscale hostname, bypassing the
1489    /// `truffle-{app_id}-{slug}` convention (pretty serving URLs).
1490    hostname: Option<String>,
1491    sidecar_path: Option<PathBuf>,
1492    state_dir: Option<String>,
1493    auth_key: Option<String>,
1494    ephemeral: bool,
1495    ws_port: u16,
1496    idle_timeout_secs: Option<u64>,
1497    /// RFC 022 Phase C: proactively exchange hello with online peers.
1498    eager_identity: bool,
1499}
1500
1501/// Manual `Debug`: `auth_key` is a tailnet credential and must never reach
1502/// logs, so it is redacted while preserving presence (`Some`/`None`).
1503impl std::fmt::Debug for NodeBuilder {
1504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1505        f.debug_struct("NodeBuilder")
1506            .field("app_id", &self.app_id)
1507            .field("device_name", &self.device_name)
1508            .field("device_id", &self.device_id)
1509            .field("hostname", &self.hostname)
1510            .field("sidecar_path", &self.sidecar_path)
1511            .field("state_dir", &self.state_dir)
1512            .field("auth_key", &self.auth_key.as_ref().map(|_| "[REDACTED]"))
1513            .field("ephemeral", &self.ephemeral)
1514            .field("ws_port", &self.ws_port)
1515            .field("idle_timeout_secs", &self.idle_timeout_secs)
1516            .field("eager_identity", &self.eager_identity)
1517            .finish()
1518    }
1519}
1520
1521/// Atomically and durably write a string to `path` by writing to a sibling
1522/// `.tmp` file first and then renaming it over the destination.
1523///
1524/// The caller persists the durable device ULID (RFC 017 §5.4), so a crash
1525/// mid-write must never lose or truncate an existing identity file:
1526/// - the temp file is fsynced before the rename publishes it, so the
1527///   destination can never be observed empty or truncated;
1528/// - on POSIX, `rename(2)` atomically replaces the destination and the
1529///   parent directory is fsynced so the rename itself survives power loss;
1530/// - on Windows, `MoveFileExW(REPLACE_EXISTING | WRITE_THROUGH)` replaces
1531///   the destination without the delete-then-rename gap that could drop
1532///   the file entirely if the process died between the two steps.
1533fn atomic_write_string(path: &Path, content: &str) -> std::io::Result<()> {
1534    let parent = path.parent().ok_or_else(|| {
1535        std::io::Error::new(
1536            std::io::ErrorKind::InvalidInput,
1537            "path has no parent directory",
1538        )
1539    })?;
1540    let mut tmp = path.to_path_buf();
1541    tmp.set_extension("tmp");
1542    {
1543        use std::io::Write as _;
1544        let mut f = std::fs::File::create(&tmp)?;
1545        f.write_all(content.as_bytes())?;
1546        f.sync_all()?;
1547    }
1548    #[cfg(unix)]
1549    {
1550        std::fs::rename(&tmp, path)?;
1551        // Best-effort: some filesystems refuse opening a directory for
1552        // fsync; the rename is still atomic without it, just not yet
1553        // guaranteed on disk.
1554        if let Ok(dir) = std::fs::File::open(parent) {
1555            let _ = dir.sync_all();
1556        }
1557    }
1558    #[cfg(windows)]
1559    {
1560        let _ = parent; // only needed for the unix dir-fsync path
1561        replace_file_windows(&tmp, path)?;
1562    }
1563    Ok(())
1564}
1565
1566/// Replace `dest` with `tmp` in one step via `MoveFileExW`.
1567///
1568/// `MOVEFILE_REPLACE_EXISTING` avoids the non-atomic remove-then-rename
1569/// dance (`std::fs::rename` errors on an existing destination on Windows);
1570/// `MOVEFILE_WRITE_THROUGH` blocks until the move is flushed to disk.
1571#[cfg(windows)]
1572fn replace_file_windows(tmp: &Path, dest: &Path) -> std::io::Result<()> {
1573    use std::os::windows::ffi::OsStrExt;
1574
1575    const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
1576    const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
1577
1578    #[link(name = "kernel32")]
1579    extern "system" {
1580        fn MoveFileExW(from: *const u16, to: *const u16, flags: u32) -> i32;
1581    }
1582
1583    fn wide(p: &Path) -> Vec<u16> {
1584        p.as_os_str()
1585            .encode_wide()
1586            .chain(std::iter::once(0))
1587            .collect()
1588    }
1589
1590    let (from, to) = (wide(tmp), wide(dest));
1591    let ok = unsafe {
1592        MoveFileExW(
1593            from.as_ptr(),
1594            to.as_ptr(),
1595            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
1596        )
1597    };
1598    if ok == 0 {
1599        return Err(std::io::Error::last_os_error());
1600    }
1601    Ok(())
1602}
1603
1604impl Default for NodeBuilder {
1605    fn default() -> Self {
1606        Self {
1607            app_id: None,
1608            device_name: None,
1609            device_id: None,
1610            sidecar_path: None,
1611            state_dir: None,
1612            auth_key: None,
1613            hostname: None,
1614            ephemeral: false,
1615            ws_port: 9417,
1616            idle_timeout_secs: None,
1617            eager_identity: true,
1618        }
1619    }
1620}
1621
1622impl NodeBuilder {
1623    /// Set the application namespace identifier (RFC 017 §5.1).
1624    ///
1625    /// The input is validated against `^[a-z][a-z0-9-]{1,31}$`. Invalid
1626    /// values are rejected with `NodeError::BuildError`.
1627    pub fn app_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
1628        let raw: String = s.into();
1629        let app_id = AppId::parse(&raw)
1630            .map_err(|e| NodeError::BuildError(format!("invalid app_id: {e}")))?;
1631        self.app_id = Some(app_id);
1632        Ok(self)
1633    }
1634
1635    /// Set the human-readable device name.
1636    ///
1637    /// Accepts any Unicode input; soft-truncated to 256 graphemes. When
1638    /// unset, the builder falls back to `hostname::get()` at `build()` time.
1639    pub fn device_name(mut self, s: impl Into<String>) -> Self {
1640        self.device_name = Some(DeviceName::parse(s));
1641        self
1642    }
1643
1644    /// Override the composed Tailscale hostname (RFC 023 §6.4).
1645    ///
1646    /// Bypasses the `truffle-{app_id}-{slug(device_name)}` convention so
1647    /// serving URLs read like a product (`https://dashboard.{tailnet}.ts.net`).
1648    /// Validated as a single lowercase DNS label (1–63 chars of `[a-z0-9-]`,
1649    /// no leading/trailing hyphen, no dots — tsnet takes a bare hostname).
1650    ///
1651    /// Tradeoff: hello-less peers with a custom hostname are not resolvable
1652    /// by bare device name (the hostname-slug convention no longer matches);
1653    /// full hostname, dnsName, IP, device-id, and post-hello identity
1654    /// resolution are unaffected. Tailscale dedupes hostname collisions by
1655    /// suffixing `-1`/`-2` — read the granted name from
1656    /// `local_info().dns_name` instead of string-building URLs.
1657    pub fn hostname(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
1658        let raw: String = s.into();
1659        validate_hostname_label(&raw).map_err(NodeError::BuildError)?;
1660        self.hostname = Some(raw);
1661        Ok(self)
1662    }
1663
1664    /// Override the auto-generated device ID.
1665    ///
1666    /// Validates that `s` is a well-formed ULID. When provided, the value
1667    /// is persisted to `{state_dir}/device-id.txt` during `build()` so
1668    /// subsequent starts without an explicit `device_id` see it.
1669    pub fn device_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
1670        let raw: String = s.into();
1671        let device_id = DeviceId::parse(&raw)
1672            .map_err(|e| NodeError::BuildError(format!("invalid device_id: {e}")))?;
1673        self.device_id = Some(device_id);
1674        Ok(self)
1675    }
1676
1677    /// Set the path to the Go sidecar binary.
1678    pub fn sidecar_path(mut self, path: impl Into<PathBuf>) -> Self {
1679        self.sidecar_path = Some(path.into());
1680        self
1681    }
1682
1683    /// Set the Tailscale state directory.
1684    pub fn state_dir(mut self, dir: &str) -> Self {
1685        self.state_dir = Some(dir.to_string());
1686        self
1687    }
1688
1689    /// Set the Tailscale auth key for headless authentication.
1690    pub fn auth_key(mut self, key: &str) -> Self {
1691        self.auth_key = Some(key.to_string());
1692        self
1693    }
1694
1695    /// Set whether the node is ephemeral (auto-removed from tailnet on shutdown).
1696    pub fn ephemeral(mut self, val: bool) -> Self {
1697        self.ephemeral = val;
1698        self
1699    }
1700
1701    /// Set the WebSocket listen port.
1702    /// RFC 022 Phase C: when true (default), proactively exchange hello with
1703    /// online peers so durable `device_id` is learned without app `send`.
1704    pub fn eager_identity(mut self, enabled: bool) -> Self {
1705        self.eager_identity = enabled;
1706        self
1707    }
1708
1709    pub fn ws_port(mut self, port: u16) -> Self {
1710        self.ws_port = port;
1711        self
1712    }
1713
1714    /// Set the idle timeout (in seconds) for bridged raw TCP connections.
1715    ///
1716    /// The sidecar reaps quiet bridged connections after this long
1717    /// (default: 600 s). Apps holding long-lived quiet sockets should
1718    /// raise this or send application-level keepalives.
1719    pub fn idle_timeout_secs(mut self, secs: u64) -> Self {
1720        self.idle_timeout_secs = Some(secs);
1721        self
1722    }
1723
1724    /// Resolve RFC 017 identity values and the Tailscale config.
1725    ///
1726    /// Shared between [`build()`](Self::build) and
1727    /// [`build_with_auth_handler()`](Self::build_with_auth_handler). Returns
1728    /// the ready-to-start `TailscaleConfig` along with the parsed identity
1729    /// triple.
1730    fn prepare_config(&self) -> Result<TailscaleConfig, NodeError> {
1731        // 1. sidecar binary is required.
1732        let binary_path = self
1733            .sidecar_path
1734            .clone()
1735            .ok_or_else(|| NodeError::BuildError("sidecar_path is required".into()))?;
1736
1737        // 2. app_id is required.
1738        let app_id = self
1739            .app_id
1740            .clone()
1741            .ok_or_else(|| NodeError::BuildError("app_id is required".into()))?;
1742
1743        // 3. device_name falls back to the OS hostname.
1744        let device_name = match self.device_name.clone() {
1745            Some(name) => name,
1746            None => {
1747                let os_hostname = hostname::get()
1748                    .map_err(|e| {
1749                        NodeError::BuildError(format!(
1750                            "device_name is unset and hostname::get() failed: {e}"
1751                        ))
1752                    })?
1753                    .to_string_lossy()
1754                    .into_owned();
1755                DeviceName::parse(os_hostname)
1756            }
1757        };
1758
1759        // 4. Compose the Tailscale hostname once, here. Downstream code
1760        //    MUST NOT rebuild it — the provider config stores this verbatim.
1761        //    An explicit builder hostname (RFC 023 §6.4) wins over the
1762        //    `truffle-{app_id}-{slug}` convention; see the setter for what
1763        //    that costs (bare-name resolution of hello-less peers).
1764        let tailscale_host = match self.hostname.clone() {
1765            Some(hostname) => hostname,
1766            None => identity::tailscale_hostname(&app_id, &device_name),
1767        };
1768
1769        // 5. Resolve state_dir. Default:
1770        //    `{dirs::data_dir}/truffle/{app_id}/{slug(device_name)}`.
1771        //    No temp-dir fallback: state_dir holds the durable device ULID
1772        //    and tsnet keys, and a temp directory silently resets both on
1773        //    reboot. Platforms without a data dir must opt in explicitly.
1774        let state_dir = match self.state_dir.clone() {
1775            Some(dir) => dir,
1776            None => {
1777                let base = dirs::data_dir().ok_or_else(|| {
1778                    NodeError::BuildError(
1779                        "no platform data directory available (dirs::data_dir() returned None); \
1780                         set state_dir explicitly to a durable location"
1781                            .into(),
1782                    )
1783                })?;
1784                base.join("truffle")
1785                    .join(app_id.as_str())
1786                    .join(identity::slug(device_name.as_str(), 255))
1787                    .to_string_lossy()
1788                    .into_owned()
1789            }
1790        };
1791
1792        // 6. Ensure the state directory exists before Tailscale starts.
1793        std::fs::create_dir_all(&state_dir)?;
1794
1795        // 7. Resolve device_id. Priority:
1796        //    a) explicit builder override → validate + persist
1797        //    b) existing `device-id.txt` → read + validate
1798        //    c) generate + persist
1799        let device_id_file = Path::new(&state_dir).join("device-id.txt");
1800        let device_id = match self.device_id.clone() {
1801            Some(id) => {
1802                // Persist the override so later auto-generated calls see it.
1803                atomic_write_string(&device_id_file, id.as_str())?;
1804                id
1805            }
1806            None => {
1807                if device_id_file.exists() {
1808                    let s = std::fs::read_to_string(&device_id_file)?.trim().to_string();
1809                    DeviceId::parse(&s).map_err(|e| {
1810                        NodeError::BuildError(format!(
1811                            "device-id.txt at {device_id_file:?} contains an invalid ULID: {e}"
1812                        ))
1813                    })?
1814                } else {
1815                    let id = DeviceId::generate();
1816                    atomic_write_string(&device_id_file, id.as_str())?;
1817                    id
1818                }
1819            }
1820        };
1821
1822        Ok(TailscaleConfig {
1823            binary_path,
1824            app_id: app_id.as_str().to_string(),
1825            device_id: device_id.as_str().to_string(),
1826            device_name: device_name.as_str().to_string(),
1827            hostname: tailscale_host,
1828            state_dir,
1829            auth_key: self.auth_key.clone(),
1830            ephemeral: if self.ephemeral { Some(true) } else { None },
1831            tags: None,
1832            idle_timeout_secs: self.idle_timeout_secs,
1833        })
1834    }
1835
1836    /// Build and start the node.
1837    ///
1838    /// This creates the TailscaleProvider, starts it, creates the WebSocket
1839    /// transport and PeerRegistry, starts the session, and spawns the
1840    /// envelope router.
1841    ///
1842    /// # Errors
1843    ///
1844    /// Returns [`NodeError::BuildError`] if required configuration is missing,
1845    /// or propagates errors from the network provider startup.
1846    pub async fn build(self) -> Result<Node<TailscaleProvider>, NodeError> {
1847        let ws_port = self.ws_port;
1848        let eager_identity = self.eager_identity;
1849        let config = self.prepare_config()?;
1850        let state_dir = PathBuf::from(&config.state_dir);
1851
1852        let mut provider = TailscaleProvider::new(config);
1853        provider.start().await.map_err(NodeError::Network)?;
1854
1855        let network = Arc::new(provider);
1856
1857        // 2. Create WebSocket transport.
1858        let ws_config = WsConfig {
1859            port: ws_port,
1860            ..Default::default()
1861        };
1862        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
1863
1864        // 3. Create PeerRegistry and start session.
1865        let session = Arc::new(PeerRegistry::with_options(
1866            network.clone(),
1867            ws_transport,
1868            crate::session::PeerRegistryOptions {
1869                eager_identity,
1870                ..Default::default()
1871            },
1872        ));
1873        session.start().await;
1874
1875        // 4. Create the node with the envelope router.
1876        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
1877        let node = Node::from_parts(network, session, codec)
1878            .with_state_dir(state_dir)
1879            .with_ws_port(ws_port);
1880
1881        tracing::info!("node: started successfully");
1882        Ok(node)
1883    }
1884
1885    /// Build and start the node, calling `on_auth` if authentication is needed.
1886    ///
1887    /// This is identical to [`build()`](Self::build) except it subscribes to
1888    /// provider events *before* `provider.start()` blocks, forwarding
1889    /// `AuthRequired` events to the callback while waiting for authentication
1890    /// to complete.
1891    ///
1892    /// # Errors
1893    ///
1894    /// Returns [`NodeError::BuildError`] if required configuration is missing,
1895    /// or propagates errors from the network provider startup.
1896    pub async fn build_with_auth_handler(
1897        self,
1898        on_auth: impl Fn(String) + Send + 'static,
1899    ) -> Result<Node<TailscaleProvider>, NodeError> {
1900        let ws_port = self.ws_port;
1901        let eager_identity = self.eager_identity;
1902        let config = self.prepare_config()?;
1903        let state_dir = PathBuf::from(&config.state_dir);
1904
1905        let mut provider = TailscaleProvider::new(config);
1906
1907        // 2. Subscribe to peer events BEFORE start() so we capture auth URLs.
1908        let mut auth_rx = provider.peer_events();
1909
1910        // 3. Spawn a task that forwards AuthRequired events to the callback.
1911        let auth_task = tokio::spawn(async move {
1912            use crate::network::NetworkPeerEvent;
1913            loop {
1914                match auth_rx.recv().await {
1915                    Ok(NetworkPeerEvent::AuthRequired { url }) => {
1916                        on_auth(url);
1917                    }
1918                    Err(broadcast::error::RecvError::Closed) => break,
1919                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
1920                    _ => {} // Ignore other events
1921                }
1922            }
1923        });
1924
1925        // 4. Start the provider (blocks until auth completes).
1926        let start_result = provider.start().await.map_err(NodeError::Network);
1927
1928        // 5. Cancel the auth forwarding task — auth is done.
1929        auth_task.abort();
1930
1931        start_result?;
1932
1933        let network = Arc::new(provider);
1934
1935        // 6. Create WebSocket transport.
1936        let ws_config = WsConfig {
1937            port: ws_port,
1938            ..Default::default()
1939        };
1940        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
1941
1942        // 7. Create PeerRegistry and start session.
1943        let session = Arc::new(PeerRegistry::with_options(
1944            network.clone(),
1945            ws_transport,
1946            crate::session::PeerRegistryOptions {
1947                eager_identity,
1948                ..Default::default()
1949            },
1950        ));
1951        session.start().await;
1952
1953        // 8. Create the node with the envelope router.
1954        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
1955        let node = Node::from_parts(network, session, codec)
1956            .with_state_dir(state_dir)
1957            .with_ws_port(ws_port);
1958
1959        tracing::info!("node: started successfully (with auth handler)");
1960        Ok(node)
1961    }
1962}
1963
1964// ---------------------------------------------------------------------------
1965// Tests
1966// ---------------------------------------------------------------------------
1967
1968#[cfg(test)]
1969mod tests {
1970    use super::*;
1971    use crate::network::{
1972        HealthInfo, IncomingConnection, NetworkError, NetworkPeer, NetworkPeerEvent,
1973        NetworkTcpListener, NetworkUdpSocket, PeerAddr,
1974    };
1975    use crate::transport::WsConfig;
1976    use serde_json::json;
1977    use std::sync::atomic::{AtomicUsize, Ordering};
1978    use std::time::Duration;
1979    use tokio::sync::{broadcast, mpsc};
1980
1981    #[test]
1982    fn node_builder_debug_redacts_auth_key() {
1983        let builder = NodeBuilder::default().auth_key("dummy-auth-SECRET123");
1984        let dbg = format!("{builder:?}");
1985        assert!(!dbg.contains("SECRET123"));
1986        assert!(dbg.contains("[REDACTED]"));
1987    }
1988
1989    #[test]
1990    fn atomic_write_string_creates_and_replaces() {
1991        let dir =
1992            std::env::temp_dir().join(format!("truffle-atomic-write-test-{}", std::process::id()));
1993        std::fs::create_dir_all(&dir).unwrap();
1994        let path = dir.join("device-id.txt");
1995
1996        atomic_write_string(&path, "01AAAAAAAAAAAAAAAAAAAAAAAA").unwrap();
1997        assert_eq!(
1998            std::fs::read_to_string(&path).unwrap(),
1999            "01AAAAAAAAAAAAAAAAAAAAAAAA"
2000        );
2001
2002        // Replacing an existing destination must succeed on every platform
2003        // (exercises the MoveFileExW REPLACE_EXISTING path on Windows CI).
2004        atomic_write_string(&path, "01BBBBBBBBBBBBBBBBBBBBBBBB").unwrap();
2005        assert_eq!(
2006            std::fs::read_to_string(&path).unwrap(),
2007            "01BBBBBBBBBBBBBBBBBBBBBBBB"
2008        );
2009
2010        std::fs::remove_dir_all(&dir).ok();
2011    }
2012
2013    // ── Mock NetworkProvider ──────────────────────────────────────────
2014
2015    struct MockNetworkProvider {
2016        identity: NodeIdentity,
2017        local_addr: PeerAddr,
2018        peer_event_tx: broadcast::Sender<NetworkPeerEvent>,
2019        /// Pre-loaded peer list for `peers()`.
2020        mock_peers: Arc<RwLock<Vec<NetworkPeer>>>,
2021        /// Count of `stop()` invocations — lets tests assert the Node
2022        /// actually shut the provider down during teardown.
2023        stop_calls: Arc<AtomicUsize>,
2024    }
2025
2026    impl MockNetworkProvider {
2027        fn new(id: &str) -> Self {
2028            // RFC 017: align `device_id` with fixture input so tests can
2029            // reason about a single identifier.
2030            Self::new_with_device_id(id, id)
2031        }
2032
2033        /// `device_id` distinct from the tailscale id — required when a test
2034        /// lets the node hello with itself (loopback self-dial): publishing
2035        /// an identity whose device_id equals the entry's tailscale_id would
2036        /// violate RFC 022 invariant I1.
2037        fn new_with_device_id(id: &str, device_id: &str) -> Self {
2038            let (peer_event_tx, _) = broadcast::channel(64);
2039            Self {
2040                identity: NodeIdentity {
2041                    app_id: "test".to_string(),
2042                    device_id: device_id.to_string(),
2043                    device_name: format!("Test Node {id}"),
2044                    tailscale_hostname: format!("truffle-test-{id}"),
2045                    tailscale_id: id.to_string(),
2046                    dns_name: None,
2047                    ip: Some("127.0.0.1".parse().unwrap()),
2048                },
2049                local_addr: PeerAddr {
2050                    ip: Some("127.0.0.1".parse().unwrap()),
2051                    hostname: format!("truffle-test-{id}"),
2052                    dns_name: None,
2053                },
2054                peer_event_tx,
2055                mock_peers: Arc::new(RwLock::new(Vec::new())),
2056                stop_calls: Arc::new(AtomicUsize::new(0)),
2057            }
2058        }
2059
2060        fn event_sender(&self) -> broadcast::Sender<NetworkPeerEvent> {
2061            self.peer_event_tx.clone()
2062        }
2063
2064        /// Number of times `stop()` has been called on this provider.
2065        fn stop_call_count(&self) -> usize {
2066            self.stop_calls.load(Ordering::SeqCst)
2067        }
2068    }
2069
2070    impl NetworkProvider for MockNetworkProvider {
2071        async fn start(&mut self) -> Result<(), NetworkError> {
2072            Ok(())
2073        }
2074
2075        async fn stop(&self) -> Result<(), NetworkError> {
2076            self.stop_calls.fetch_add(1, Ordering::SeqCst);
2077            Ok(())
2078        }
2079
2080        fn local_identity(&self) -> NodeIdentity {
2081            self.identity.clone()
2082        }
2083
2084        fn local_addr(&self) -> PeerAddr {
2085            self.local_addr.clone()
2086        }
2087
2088        fn peer_events(&self) -> broadcast::Receiver<NetworkPeerEvent> {
2089            self.peer_event_tx.subscribe()
2090        }
2091
2092        async fn peers(&self) -> Vec<NetworkPeer> {
2093            self.mock_peers.read().await.clone()
2094        }
2095
2096        async fn dial_tcp(&self, addr: &str, port: u16) -> Result<TcpStream, NetworkError> {
2097            let target = format!("{addr}:{port}");
2098            TcpStream::connect(&target)
2099                .await
2100                .map_err(|e| NetworkError::DialFailed(format!("mock dial {target}: {e}")))
2101        }
2102
2103        async fn listen_tcp(&self, port: u16) -> Result<NetworkTcpListener, NetworkError> {
2104            let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
2105                .await
2106                .map_err(|e| NetworkError::ListenFailed(format!("mock listen :{port}: {e}")))?;
2107
2108            let actual_port = listener.local_addr().unwrap().port();
2109            let (tx, rx) = mpsc::channel::<IncomingConnection>(64);
2110
2111            tokio::spawn(async move {
2112                loop {
2113                    match listener.accept().await {
2114                        Ok((stream, addr)) => {
2115                            let conn = IncomingConnection {
2116                                stream,
2117                                remote_addr: addr.to_string(),
2118                                remote_identity: String::new(),
2119                                port: actual_port,
2120                            };
2121                            if tx.send(conn).await.is_err() {
2122                                break;
2123                            }
2124                        }
2125                        Err(e) => {
2126                            tracing::debug!("mock listener error: {e}");
2127                            break;
2128                        }
2129                    }
2130                }
2131            });
2132
2133            Ok(NetworkTcpListener {
2134                port: actual_port,
2135                incoming: rx,
2136            })
2137        }
2138
2139        async fn unlisten_tcp(&self, _port: u16) -> Result<(), NetworkError> {
2140            Ok(())
2141        }
2142
2143        async fn bind_udp(&self, _port: u16) -> Result<NetworkUdpSocket, NetworkError> {
2144            Err(NetworkError::Internal("mock: UDP not supported".into()))
2145        }
2146
2147        async fn ping(&self, _addr: &str) -> Result<PingResult, NetworkError> {
2148            Ok(PingResult {
2149                latency: Duration::from_millis(1),
2150                connection: "direct".to_string(),
2151                peer_addr: None,
2152            })
2153        }
2154
2155        async fn health(&self) -> HealthInfo {
2156            HealthInfo {
2157                state: "running".to_string(),
2158                healthy: true,
2159                ..Default::default()
2160            }
2161        }
2162    }
2163
2164    // ── Helpers ──────────────────────────────────────────────────────
2165
2166    fn make_loopback_peer(id: &str) -> NetworkPeer {
2167        NetworkPeer {
2168            id: id.to_string(),
2169            hostname: format!("truffle-test-{id}"),
2170            ip: "127.0.0.1".parse().unwrap(),
2171            online: true,
2172            cur_addr: Some("127.0.0.1:41641".to_string()),
2173            relay: None,
2174            os: Some("linux".to_string()),
2175            last_seen: Some("2026-03-25T12:00:00Z".to_string()),
2176            key_expiry: None,
2177            dns_name: None,
2178        }
2179    }
2180
2181    fn ws_config(port: u16) -> WsConfig {
2182        WsConfig {
2183            port,
2184            ping_interval: Duration::from_secs(300),
2185            pong_timeout: Duration::from_secs(300),
2186            ..Default::default()
2187        }
2188    }
2189
2190    async fn random_port() -> u16 {
2191        let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2192        l.local_addr().unwrap().port()
2193    }
2194
2195    /// Create a Node backed by a mock provider for testing.
2196    async fn make_test_node(
2197        id: &str,
2198        ws_port: u16,
2199    ) -> (
2200        Node<MockNetworkProvider>,
2201        broadcast::Sender<NetworkPeerEvent>,
2202        Arc<MockNetworkProvider>,
2203    ) {
2204        make_test_node_with_device_id(id, id, ws_port).await
2205    }
2206
2207    /// Like [`make_test_node`] but with a `device_id` distinct from the
2208    /// tailscale id — see [`MockNetworkProvider::new_with_device_id`].
2209    async fn make_test_node_with_device_id(
2210        id: &str,
2211        device_id: &str,
2212        ws_port: u16,
2213    ) -> (
2214        Node<MockNetworkProvider>,
2215        broadcast::Sender<NetworkPeerEvent>,
2216        Arc<MockNetworkProvider>,
2217    ) {
2218        let provider = MockNetworkProvider::new_with_device_id(id, device_id);
2219        let event_tx = provider.event_sender();
2220        let network = Arc::new(provider);
2221        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config(ws_port)));
2222        let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
2223        session.start().await;
2224
2225        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
2226        let node = Node::from_parts(network.clone(), session, codec);
2227
2228        (node, event_tx, network)
2229    }
2230
2231    // ── Tests ────────────────────────────────────────────────────────
2232
2233    /// `whois` semantics on a provider without WhoIs support: a raw IP passes
2234    /// straight through to the provider (surfacing `Unsupported`, never a
2235    /// fabricated identity), while a non-IP identifier still goes through
2236    /// peer resolution and fails as `PeerNotFound` for unknown peers.
2237    #[tokio::test]
2238    async fn whois_unsupported_provider_and_unknown_peer() {
2239        let ws_port = random_port().await;
2240        let (node, _event_tx, _network) = make_test_node("whois-node", ws_port).await;
2241
2242        match node.whois("100.64.0.9").await {
2243            Err(NodeError::Network(NetworkError::Unsupported(_))) => {}
2244            other => panic!("expected Unsupported for raw IP on mock provider, got {other:?}"),
2245        }
2246        // An `ip:port` string (a QUIC `remote_address()` verbatim) also
2247        // reaches the provider — the port is stripped, never PeerNotFound.
2248        match node.whois("100.64.0.9:52133").await {
2249            Err(NodeError::Network(NetworkError::Unsupported(_))) => {}
2250            other => panic!("expected Unsupported for ip:port on mock provider, got {other:?}"),
2251        }
2252        match node.whois("no-such-peer").await {
2253            Err(NodeError::PeerNotFound(_)) => {}
2254            other => panic!("expected PeerNotFound for unknown identifier, got {other:?}"),
2255        }
2256
2257        node.stop().await;
2258    }
2259
2260    // ── Exhaustion tests (review: resource-exhaustion section) ────────
2261
2262    /// Flood the real dispatch path with offers from one peer and assert
2263    /// every bound holds: per-peer pending cap, bounded app queue, and a
2264    /// prompt stop() despite parked decision waits.
2265    #[tokio::test]
2266    async fn offer_flood_respects_caps_and_stop_drains() {
2267        let ws_port = random_port().await;
2268        let (node, _event_tx, _network) = make_test_node("node-flood", ws_port).await;
2269        let node = Arc::new(node);
2270        let mut offers = node.file_transfer().offer_channel(node.clone()).await;
2271
2272        for i in 0..500u32 {
2273            let payload = json!({
2274                "type": "offer",
2275                "file_name": format!("f{i}.bin"),
2276                "size": 1,
2277                "sha256": "0".repeat(64),
2278                "save_path": "",
2279                "token": format!("tok-{i}"),
2280                "tcp_port": 0,
2281            });
2282            let envelope = Envelope::new("ft", "offer", payload).with_timestamp();
2283            node.session
2284                .test_inject_incoming("peer-flood", JsonCodec.encode(&envelope).unwrap());
2285            if i % 64 == 0 {
2286                tokio::task::yield_now().await;
2287            }
2288        }
2289        tokio::time::sleep(Duration::from_millis(300)).await;
2290
2291        let pending = node
2292            .file_transfer_state
2293            .pending_offers_per_peer
2294            .lock()
2295            .unwrap()
2296            .get("peer-flood")
2297            .copied()
2298            .unwrap_or(0);
2299        assert!(
2300            pending <= crate::file_transfer::MAX_PENDING_OFFERS_PER_PEER,
2301            "per-peer pending cap violated: {pending}"
2302        );
2303
2304        // Without an app draining it, the offer queue stays bounded.
2305        let mut buffered = 0;
2306        while offers.try_recv().is_ok() {
2307            buffered += 1;
2308        }
2309        assert!(buffered <= 36, "app offer queue grew to {buffered}");
2310
2311        // Parked offers wait up to 60s for a decision — stop() must cancel
2312        // them instead of waiting that out.
2313        let started = std::time::Instant::now();
2314        node.stop().await;
2315        assert!(
2316            started.elapsed() < Duration::from_secs(5),
2317            "stop() took {:?} with parked offers",
2318            started.elapsed()
2319        );
2320        assert!(node.tasks.tracker.is_empty());
2321    }
2322
2323    /// Churning through dynamic namespaces must not grow the filter map.
2324    #[tokio::test]
2325    async fn dynamic_namespace_churn_stays_bounded() {
2326        let ws_port = random_port().await;
2327        let (node, _event_tx, _network) = make_test_node("node-ns-churn", ws_port).await;
2328        for i in 0..1000 {
2329            let rx = node.subscribe(&format!("dyn-{i}"));
2330            drop(rx);
2331        }
2332        let len = node
2333            .namespace_filters
2334            .read()
2335            .unwrap_or_else(std::sync::PoisonError::into_inner)
2336            .len();
2337        assert!(len <= 1, "namespace map grew to {len} entries");
2338    }
2339
2340    /// Repeated build → use → stop cycles leave no background tasks behind.
2341    #[tokio::test]
2342    async fn lifecycle_churn_leaves_no_tasks() {
2343        for _ in 0..25 {
2344            let ws_port = random_port().await;
2345            let (node, _event_tx, _network) = make_test_node("node-churn", ws_port).await;
2346            let node = Arc::new(node);
2347            let _rx = node.subscribe("churn");
2348            let _offers = node.file_transfer().offer_channel(node.clone()).await;
2349            node.stop().await;
2350            assert!(node.tasks.tracker.is_empty(), "task leak after stop()");
2351        }
2352    }
2353
2354    /// A subscriber that never polls observes Lagged (bounded channel)
2355    /// rather than causing unbounded buffering.
2356    #[tokio::test]
2357    async fn slow_subscriber_lags_instead_of_growing() {
2358        let ws_port = random_port().await;
2359        let (node, _event_tx, _network) = make_test_node("node-lag", ws_port).await;
2360        let mut rx = node.subscribe("chat");
2361
2362        for i in 0..2000 {
2363            let envelope = Envelope::new("chat", "message", json!({ "i": i })).with_timestamp();
2364            node.session
2365                .test_inject_incoming("peer-x", JsonCodec.encode(&envelope).unwrap());
2366            if i % 100 == 0 {
2367                tokio::task::yield_now().await;
2368            }
2369        }
2370        tokio::time::sleep(Duration::from_millis(300)).await;
2371
2372        let mut lagged = false;
2373        loop {
2374            match rx.try_recv() {
2375                Ok(_) => {}
2376                Err(broadcast::error::TryRecvError::Lagged(_)) => lagged = true,
2377                Err(_) => break,
2378            }
2379        }
2380        assert!(lagged, "slow subscriber should observe Lagged");
2381    }
2382
2383    #[tokio::test]
2384    async fn stop_drains_all_background_tasks() {
2385        let ws_port = random_port().await;
2386        let (node, _event_tx, _network) = make_test_node("node-drain", ws_port).await;
2387        let node = Arc::new(node);
2388
2389        // Router is running; also start the FT dispatch task.
2390        let _offers = node.file_transfer().offer_channel(node.clone()).await;
2391        assert!(!node.tasks.tracker.is_empty(), "tasks should be running");
2392
2393        node.stop().await;
2394
2395        // stop() returns only after every node-scoped task has finished.
2396        assert!(
2397            node.tasks.tracker.is_empty(),
2398            "background tasks still alive after stop()"
2399        );
2400        assert!(
2401            node.file_transfer_state
2402                .receiver_handle
2403                .lock()
2404                .await
2405                .is_none(),
2406            "FT receiver handle not cleared by stop()"
2407        );
2408    }
2409
2410    #[tokio::test]
2411    async fn stopped_node_fails_raw_and_resolution_apis_fast() {
2412        let ws_port = random_port().await;
2413        let (node, _event_tx, _network) = make_test_node("node-guards", ws_port).await;
2414        node.stop().await;
2415
2416        assert!(matches!(
2417            node.open_tcp("nobody", 4242).await.unwrap_err(),
2418            NodeError::Stopped
2419        ));
2420        assert!(matches!(
2421            node.listen_tcp(4242).await.unwrap_err(),
2422            NodeError::Stopped
2423        ));
2424        assert!(matches!(
2425            node.connect_quic("nobody", 4242).await.unwrap_err(),
2426            NodeError::Stopped
2427        ));
2428        assert!(matches!(
2429            node.listen_quic(4242).await.unwrap_err(),
2430            NodeError::Stopped
2431        ));
2432        assert!(matches!(
2433            node.bind_udp(4242).await.err(),
2434            Some(NodeError::Stopped)
2435        ));
2436        assert!(matches!(
2437            node.ping("nobody").await.unwrap_err(),
2438            NodeError::Stopped
2439        ));
2440        assert!(matches!(
2441            node.resolve_peer_id("nobody").await.unwrap_err(),
2442            NodeError::Stopped
2443        ));
2444    }
2445
2446    #[test]
2447    fn bytes_payload_roundtrip() {
2448        // Deliberately invalid UTF-8: the representation must not depend
2449        // on the data's contents.
2450        let data = vec![0u8, 159, 146, 150, 255];
2451        let payload = Node::<MockNetworkProvider>::bytes_payload(&data);
2452        assert_eq!(payload["encoding"], "base64");
2453
2454        let msg = NamespacedMessage {
2455            from: "p".into(),
2456            namespace: "ns".into(),
2457            msg_type: "bytes".into(),
2458            payload,
2459            timestamp: None,
2460        };
2461        assert_eq!(msg.payload_bytes().unwrap(), data);
2462    }
2463
2464    #[test]
2465    fn payload_bytes_rejects_other_msg_types() {
2466        let msg = NamespacedMessage {
2467            from: "p".into(),
2468            namespace: "ns".into(),
2469            msg_type: "message".into(),
2470            payload: json!({"data": "aGk="}),
2471            timestamp: None,
2472        };
2473        assert!(msg.payload_bytes().is_none());
2474    }
2475
2476    #[tokio::test]
2477    async fn broadcast_json_reports_zero_peers() {
2478        let ws_port = random_port().await;
2479        let (node, _event_tx, _network) = make_test_node("node-bj", ws_port).await;
2480
2481        let report = node.broadcast_json("ns", &json!({"a": 1})).await.unwrap();
2482        assert_eq!(report.attempted, 0);
2483        assert_eq!(report.queued, 0);
2484        assert!(report.failed.is_empty());
2485    }
2486
2487    #[tokio::test]
2488    async fn subscribe_slow_path_sweeps_dead_namespaces() {
2489        let ws_port = random_port().await;
2490        let (node, _event_tx, _network) = make_test_node("node-sweep", ws_port).await;
2491
2492        let rx = node.subscribe("dyn-a");
2493        drop(rx);
2494        // Creating a NEW namespace takes the slow path, which sweeps
2495        // entries with no live receivers.
2496        let _rx_b = node.subscribe("dyn-b");
2497
2498        let filters = node.namespace_filters.read().unwrap();
2499        assert!(!filters.contains_key("dyn-a"), "dead entry not swept");
2500        assert!(filters.contains_key("dyn-b"));
2501    }
2502
2503    #[tokio::test]
2504    async fn router_prunes_namespace_after_receivers_drop() {
2505        let ws_port = random_port().await;
2506        let (node, _event_tx, _network) = make_test_node("node-prune", ws_port).await;
2507
2508        let rx = node.subscribe("dyn-c");
2509        drop(rx);
2510
2511        // Drive the router with a message for the now-dead namespace.
2512        let envelope = Envelope::new("dyn-c", "message", json!({"x": 1})).with_timestamp();
2513        let data = JsonCodec.encode(&envelope).unwrap();
2514        node.session.test_inject_incoming("peer-x", data);
2515
2516        // The router runs on a background task; poll for the prune.
2517        for _ in 0..50 {
2518            {
2519                let filters = node.namespace_filters.read().unwrap();
2520                if !filters.contains_key("dyn-c") {
2521                    return;
2522                }
2523            }
2524            tokio::time::sleep(Duration::from_millis(10)).await;
2525        }
2526        panic!("dead namespace entry was not pruned by the router");
2527    }
2528
2529    #[tokio::test]
2530    async fn test_node_builder_creates_node() {
2531        let ws_port = random_port().await;
2532        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2533
2534        let identity = node.local_info();
2535        assert_eq!(identity.tailscale_id, "node-1");
2536        assert_eq!(identity.device_id, "node-1");
2537        assert!(identity.tailscale_hostname.contains("node-1"));
2538    }
2539
2540    #[tokio::test]
2541    async fn test_node_peers_from_network() {
2542        let ws_port = random_port().await;
2543        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
2544
2545        // Initially no peers.
2546        let peers = node.peers().await;
2547        assert!(peers.is_empty());
2548
2549        // Inject a peer via Layer 3.
2550        let peer = make_loopback_peer("peer-a");
2551        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2552        tokio::time::sleep(Duration::from_millis(50)).await;
2553
2554        let peers = node.peers().await;
2555        assert_eq!(peers.len(), 1);
2556        assert_eq!(peers[0].tailscale_id, "peer-a");
2557        assert!(
2558            peers[0].device_id.is_none(),
2559            "RFC 022: no ULID before identity"
2560        );
2561        assert!(peers[0].online);
2562        assert!(!peers[0].ws_connected);
2563    }
2564
2565    #[tokio::test]
2566    #[allow(deprecated)] // exercises the legacy send/broadcast contract
2567    async fn test_node_send_to_unknown_peer_errors() {
2568        let ws_port = random_port().await;
2569        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2570
2571        let result = node.send("nonexistent", "test", b"hello").await;
2572        assert!(result.is_err());
2573        let err_str = result.unwrap_err().to_string();
2574        assert!(
2575            err_str.contains("unknown peer") || err_str.contains("not found"),
2576            "expected unknown peer error, got: {err_str}"
2577        );
2578    }
2579
2580    #[tokio::test]
2581    async fn test_node_send_wraps_in_envelope() {
2582        // Test that send() properly creates an envelope.
2583        // We test the codec directly since a full send requires two connected nodes.
2584        let codec = JsonCodec;
2585        let data = b"hello world";
2586        let envelope = Envelope::new("test-ns", "message", serde_json::Value::from(data.to_vec()))
2587            .with_timestamp();
2588
2589        let encoded = codec.encode(&envelope).unwrap();
2590        let decoded = codec.decode(&encoded).unwrap();
2591
2592        assert_eq!(decoded.namespace, "test-ns");
2593        assert_eq!(decoded.msg_type, "message");
2594        assert!(decoded.timestamp.is_some());
2595    }
2596
2597    #[tokio::test]
2598    async fn test_node_subscribe_filters_by_namespace() {
2599        let ws_port = random_port().await;
2600        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2601
2602        // Create subscribers for two different namespaces.
2603        let _rx_chat = node.subscribe("chat");
2604        let _rx_ft = node.subscribe("ft");
2605
2606        // Subscribing to the same namespace again should work.
2607        let _rx_chat2 = node.subscribe("chat");
2608    }
2609
2610    #[tokio::test]
2611    #[allow(deprecated)] // exercises the legacy send/broadcast contract
2612    async fn test_node_broadcast() {
2613        let ws_port = random_port().await;
2614        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2615
2616        // Broadcast with no connected peers should not panic.
2617        node.broadcast("test", b"hello everyone").await;
2618    }
2619
2620    #[tokio::test]
2621    async fn test_node_open_tcp_resolves_peer() {
2622        let ws_port = random_port().await;
2623        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
2624
2625        // Start a TCP server for the test.
2626        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2627        let tcp_port = listener.local_addr().unwrap().port();
2628
2629        // Inject a loopback peer.
2630        let peer = make_loopback_peer("peer-tcp");
2631        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2632        tokio::time::sleep(Duration::from_millis(50)).await;
2633
2634        // Accept a connection in the background.
2635        let accept_handle = tokio::spawn(async move {
2636            let (stream, _) = listener.accept().await.unwrap();
2637            stream
2638        });
2639
2640        // open_tcp should resolve peer-tcp to 127.0.0.1 and connect.
2641        let stream = node.open_tcp("peer-tcp", tcp_port).await;
2642        assert!(stream.is_ok(), "open_tcp failed: {:?}", stream.err());
2643
2644        let _ = accept_handle.await;
2645    }
2646
2647    #[tokio::test]
2648    async fn test_node_open_tcp_unknown_peer_errors() {
2649        let ws_port = random_port().await;
2650        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2651
2652        let result = node.open_tcp("nonexistent", 8080).await;
2653        assert!(result.is_err());
2654        let err_str = result.unwrap_err().to_string();
2655        assert!(
2656            err_str.contains("not found"),
2657            "expected peer not found error, got: {err_str}"
2658        );
2659    }
2660
2661    #[tokio::test]
2662    async fn test_node_ping_resolves_peer() {
2663        let ws_port = random_port().await;
2664        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
2665
2666        // No peer yet.
2667        let result = node.ping("peer-ping").await;
2668        assert!(result.is_err());
2669
2670        // Inject peer.
2671        let peer = make_loopback_peer("peer-ping");
2672        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2673        tokio::time::sleep(Duration::from_millis(50)).await;
2674
2675        // Should succeed (mock returns 1ms latency).
2676        let result = node.ping("peer-ping").await;
2677        assert!(result.is_ok());
2678        assert_eq!(result.unwrap().latency, Duration::from_millis(1));
2679    }
2680
2681    #[tokio::test]
2682    async fn test_node_health() {
2683        let ws_port = random_port().await;
2684        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2685
2686        let health = node.health().await;
2687        assert!(health.healthy);
2688        assert_eq!(health.state, "running");
2689    }
2690
2691    #[tokio::test]
2692    async fn test_node_connect_quic_unknown_peer_errors() {
2693        let ws_port = random_port().await;
2694        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2695
2696        let result = node.connect_quic("peer", 4433).await;
2697        assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
2698    }
2699
2700    #[tokio::test]
2701    async fn test_node_listen_tcp() {
2702        let ws_port = random_port().await;
2703        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2704
2705        // listen_tcp(0) should bind to an ephemeral port.
2706        let listener = node.listen_tcp(0).await;
2707        assert!(listener.is_ok(), "listen_tcp failed: {:?}", listener.err());
2708    }
2709
2710    #[tokio::test]
2711    async fn test_envelope_serialize_deserialize() {
2712        let envelope = Envelope::new("chat", "message", json!({"text": "hello"})).with_timestamp();
2713
2714        let bytes = envelope.serialize().unwrap();
2715        let decoded = Envelope::deserialize(&bytes).unwrap();
2716
2717        assert_eq!(decoded.namespace, "chat");
2718        assert_eq!(decoded.msg_type, "message");
2719        assert_eq!(decoded.payload["text"], "hello");
2720        assert!(decoded.timestamp.is_some());
2721    }
2722
2723    #[tokio::test]
2724    async fn test_envelope_codec_json() {
2725        let codec = JsonCodec;
2726        let envelope = Envelope::new("ft", "offer", json!({"file": "test.bin"}));
2727
2728        let encoded = codec.encode(&envelope).unwrap();
2729        let decoded = codec.decode(&encoded).unwrap();
2730
2731        assert_eq!(decoded.namespace, "ft");
2732        assert_eq!(decoded.payload["file"], "test.bin");
2733    }
2734
2735    #[tokio::test]
2736    async fn test_envelope_unknown_fields_ignored() {
2737        let json_bytes = br#"{
2738            "namespace": "v2",
2739            "msg_type": "new",
2740            "payload": {},
2741            "future_field": "ignored"
2742        }"#;
2743
2744        let codec = JsonCodec;
2745        let decoded = codec.decode(json_bytes).unwrap();
2746        assert_eq!(decoded.namespace, "v2");
2747        assert_eq!(decoded.msg_type, "new");
2748    }
2749
2750    #[tokio::test]
2751    #[allow(deprecated)] // exercises the legacy send/broadcast contract
2752    async fn test_node_send_and_receive_roundtrip() {
2753        // Set up two nodes that communicate via loopback WS.
2754        let port_a = random_port().await;
2755        let port_b = random_port().await;
2756
2757        let (node_a, event_tx_a, _net_a) = make_test_node("node-a", port_a).await;
2758        let (node_b, event_tx_b, _net_b) = make_test_node("node-b", port_b).await;
2759
2760        // Inject each node as a peer of the other.
2761        let peer_b = NetworkPeer {
2762            id: "node-b".to_string(),
2763            hostname: "truffle-test-node-b".to_string(),
2764            ip: "127.0.0.1".parse().unwrap(),
2765            online: true,
2766            cur_addr: Some("127.0.0.1:41641".to_string()),
2767            relay: None,
2768            os: None,
2769            last_seen: None,
2770            key_expiry: None,
2771            dns_name: None,
2772        };
2773        let peer_a = NetworkPeer {
2774            id: "node-a".to_string(),
2775            hostname: "truffle-test-node-a".to_string(),
2776            ip: "127.0.0.1".parse().unwrap(),
2777            online: true,
2778            cur_addr: Some("127.0.0.1:41641".to_string()),
2779            relay: None,
2780            os: None,
2781            last_seen: None,
2782            key_expiry: None,
2783            dns_name: None,
2784        };
2785
2786        let _ = event_tx_a.send(NetworkPeerEvent::Joined(peer_b));
2787        let _ = event_tx_b.send(NetworkPeerEvent::Joined(peer_a));
2788        tokio::time::sleep(Duration::from_millis(100)).await;
2789
2790        // Subscribe to namespace on node_b.
2791        let mut rx = node_b.subscribe("test");
2792
2793        // Send from node_a to node_b. This triggers lazy WS connect.
2794        // Note: this will connect to node_b's WS listener on port_b.
2795        let send_result = node_a.send("node-b", "test", b"hello from a").await;
2796
2797        // The send may fail in loopback mock because the WS port for node-b
2798        // is the listener port, and the mock's dial connects to 127.0.0.1:port_b.
2799        // In a real scenario with Tailscale, this works because each node
2800        // listens on its own Tailscale IP.
2801        //
2802        // For unit tests, we verify the envelope codec roundtrip works.
2803        // Full integration tests require two separate processes.
2804        if send_result.is_ok() {
2805            // If send succeeded, verify the message arrives.
2806            let msg = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await;
2807            if let Ok(Ok(msg)) = msg {
2808                assert_eq!(msg.namespace, "test");
2809            }
2810        }
2811        // If send fails due to loopback WS peer-id mismatch, that's expected
2812        // in unit tests. The important thing is no panics.
2813    }
2814
2815    // ── Lifecycle: stop() teardown ───────────────────────────────────
2816
2817    #[tokio::test]
2818    #[allow(deprecated)] // exercises the legacy send/broadcast contract
2819    async fn test_node_stop_shuts_down_provider_and_is_idempotent() {
2820        let ws_port = random_port().await;
2821        let (node, event_tx, network) = make_test_node("node-1", ws_port).await;
2822
2823        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-1")));
2824        tokio::time::sleep(Duration::from_millis(50)).await;
2825
2826        assert_eq!(network.stop_call_count(), 0);
2827        node.stop().await;
2828        assert_eq!(
2829            network.stop_call_count(),
2830            1,
2831            "stop() must shut down the provider"
2832        );
2833
2834        // Idempotent: second stop must not stop the provider again.
2835        node.stop().await;
2836        assert_eq!(network.stop_call_count(), 1);
2837
2838        // Post-stop sends fail with NodeError::Stopped.
2839        let err = node.send("peer-1", "test", b"hi").await.unwrap_err();
2840        assert!(matches!(err, NodeError::Stopped));
2841    }
2842
2843    #[tokio::test]
2844    #[allow(deprecated)] // exercises the legacy send/broadcast contract
2845    async fn test_node_stop_closes_ws_connections() {
2846        // A single node that discovers itself as a loopback peer. Under the
2847        // loopback mock every dial lands on the node's own listener, and the
2848        // RFC 022 dial-side identity check drops any connection whose
2849        // answerer is not the dialed peer — so the only WS a mock node can
2850        // legitimately establish is to an entry carrying its own
2851        // tailscale_id. That is all this test needs: a live WS connection
2852        // for stop() to tear down. The distinct device_id keeps the
2853        // self-hello from violating invariant I1 on projection.
2854        let port_a = random_port().await;
2855        let (node_a, event_tx_a, _net_a) =
2856            make_test_node_with_device_id("node-a", "dev-node-a", port_a).await;
2857
2858        let self_peer = NetworkPeer {
2859            id: "node-a".to_string(),
2860            hostname: "truffle-test-node-a".to_string(),
2861            ip: "127.0.0.1".parse().unwrap(),
2862            online: true,
2863            cur_addr: Some("127.0.0.1:41641".to_string()),
2864            relay: None,
2865            os: None,
2866            last_seen: None,
2867            key_expiry: None,
2868            dns_name: None,
2869        };
2870        let _ = event_tx_a.send(NetworkPeerEvent::Joined(self_peer));
2871        tokio::time::sleep(Duration::from_millis(100)).await;
2872
2873        node_a
2874            .send("node-a", "test", b"hello from a")
2875            .await
2876            .expect("loopback self-dial should establish a WS connection");
2877
2878        let peers = node_a.peers().await;
2879        let entry = peers
2880            .iter()
2881            .find(|p| p.tailscale_id == "node-a")
2882            .expect("self peer discovered");
2883        assert!(
2884            entry.ws_connected,
2885            "send() should have established a WS connection"
2886        );
2887
2888        node_a.stop().await;
2889        let peers = node_a.peers().await;
2890        let entry = peers
2891            .iter()
2892            .find(|p| p.tailscale_id == "node-a")
2893            .expect("self peer still discovered");
2894        assert!(
2895            !entry.ws_connected,
2896            "stop() must close and un-mark WS connections"
2897        );
2898    }
2899
2900    // ── RFC 017 Phase 2: resolve_peer_id ─────────────────────────────
2901
2902    /// Helper: inject a peer entry into the session registry and then
2903    /// stamp a synthetic RFC 017 identity on it so `resolve_peer_id`
2904    /// has something to look up. This skips the real hello exchange.
2905    async fn inject_peer_with_identity(
2906        node: &Node<MockNetworkProvider>,
2907        event_tx: &broadcast::Sender<NetworkPeerEvent>,
2908        tailscale_id: &str,
2909        device_id: &str,
2910        device_name: &str,
2911    ) {
2912        let peer = make_loopback_peer(tailscale_id);
2913        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2914        tokio::time::sleep(Duration::from_millis(30)).await;
2915
2916        let identity = crate::session::PeerIdentity {
2917            app_id: "test".into(),
2918            device_id: device_id.into(),
2919            device_name: device_name.into(),
2920            os: "linux".into(),
2921            tailscale_id: tailscale_id.into(),
2922        };
2923        assert!(
2924            node.session
2925                .test_stamp_identity(tailscale_id, identity)
2926                .await,
2927            "peer {tailscale_id} should exist in session registry before stamping identity"
2928        );
2929    }
2930
2931    #[tokio::test]
2932    async fn test_resolve_peer_id_by_device_id() {
2933        let ws_port = random_port().await;
2934        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2935
2936        inject_peer_with_identity(
2937            &node,
2938            &event_tx,
2939            "tailscale-abc",
2940            "01HZZZZZZZZZZZZZZZZZZZZZZZ",
2941            "Alice MacBook",
2942        )
2943        .await;
2944
2945        let resolved = node
2946            .resolve_peer_id("01HZZZZZZZZZZZZZZZZZZZZZZZ")
2947            .await
2948            .unwrap();
2949        assert_eq!(resolved, "01HZZZZZZZZZZZZZZZZZZZZZZZ");
2950    }
2951
2952    #[tokio::test]
2953    async fn test_resolve_peer_id_by_device_name() {
2954        let ws_port = random_port().await;
2955        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2956
2957        inject_peer_with_identity(
2958            &node,
2959            &event_tx,
2960            "tailscale-abc",
2961            "01HXYZXYZXYZXYZXYZXYZXYZXY",
2962            "Bob's Mac",
2963        )
2964        .await;
2965
2966        let resolved = node.resolve_peer_id("Bob's Mac").await.unwrap();
2967        assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
2968    }
2969
2970    #[tokio::test]
2971    async fn test_resolve_peer_id_by_device_id_prefix() {
2972        let ws_port = random_port().await;
2973        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2974
2975        inject_peer_with_identity(
2976            &node,
2977            &event_tx,
2978            "tailscale-abc",
2979            "01HXYZXYZXYZXYZXYZXYZXYZXY",
2980            "laptop",
2981        )
2982        .await;
2983
2984        // Prefix match — 4 chars is the minimum the implementation
2985        // accepts.
2986        let resolved = node.resolve_peer_id("01HX").await.unwrap();
2987        assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
2988    }
2989
2990    #[tokio::test]
2991    async fn test_resolve_peer_id_by_tailscale_id_legacy() {
2992        // Escape hatch: resolving by the Tailscale stable ID should
2993        // still work and return the device_id (or the tailscale_id as
2994        // fallback when no identity is populated).
2995        let ws_port = random_port().await;
2996        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2997
2998        inject_peer_with_identity(
2999            &node,
3000            &event_tx,
3001            "tailscale-legacy",
3002            "01HLEGACY0000000000000000X",
3003            "legacy box",
3004        )
3005        .await;
3006
3007        let resolved = node.resolve_peer_id("tailscale-legacy").await.unwrap();
3008        assert_eq!(resolved, "01HLEGACY0000000000000000X");
3009    }
3010
3011    #[tokio::test]
3012    async fn test_resolve_peer_id_unknown() {
3013        let ws_port = random_port().await;
3014        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
3015        let result = node.resolve_peer_id("nope").await;
3016        assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
3017    }
3018
3019    // ── RFC 021: raw transport surface ────────────────────────────────
3020
3021    #[tokio::test]
3022    async fn test_resolve_peer_by_bare_name_before_hello() {
3023        // Raw-transport-only peers have no hello identity; the bare device
3024        // name must still resolve via the hostname slug (RFC 021 smoke-test
3025        // finding: only the full `truffle-{app}-{slug}` hostname matched).
3026        let ws_port = random_port().await;
3027        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
3028
3029        let mut peer = make_loopback_peer("nodeid-9");
3030        peer.hostname = "truffle-test-ec2-smoke".to_string();
3031        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
3032        tokio::time::sleep(Duration::from_millis(50)).await;
3033
3034        // Bare slug, and an unslugged variant that normalizes to it.
3035        assert_eq!(node.resolve_peer("ec2-smoke").await.unwrap().id, "nodeid-9");
3036        assert_eq!(node.resolve_peer("EC2 Smoke").await.unwrap().id, "nodeid-9");
3037        // Unknown bare names still miss.
3038        assert!(node.resolve_peer("other-box").await.is_err());
3039    }
3040
3041    #[tokio::test]
3042    async fn test_peers_device_name_strips_hostname_before_hello() {
3043        let ws_port = random_port().await;
3044        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
3045
3046        let mut peer = make_loopback_peer("nodeid-9");
3047        peer.hostname = "truffle-test-ec2-smoke".to_string();
3048        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
3049        tokio::time::sleep(Duration::from_millis(50)).await;
3050
3051        let peers = node.peers().await;
3052        assert_eq!(peers.len(), 1);
3053        // Pre-identity: device_name is None; display_name uses stripped slug.
3054        assert!(peers[0].device_name.is_none());
3055        assert_eq!(peers[0].display_name, "ec2-smoke");
3056        assert_eq!(peers[0].hostname, "truffle-test-ec2-smoke");
3057        assert!(peers[0].device_id.is_none());
3058    }
3059
3060    #[tokio::test]
3061    async fn test_resolve_peer_stale_ref_is_peer_gone() {
3062        let ws_port = random_port().await;
3063        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
3064
3065        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-9")));
3066        tokio::time::sleep(Duration::from_millis(50)).await;
3067
3068        let live_ref = node.peers().await[0].peer_ref.clone();
3069        assert_eq!(node.resolve_peer(&live_ref).await.unwrap().id, "peer-9");
3070
3071        // Left + rejoin bumps the generation: the old handle's ref must fail
3072        // with PeerGone (RFC 022 I5), never silently reach the new
3073        // generation.
3074        let _ = event_tx.send(NetworkPeerEvent::Left("peer-9".to_string()));
3075        tokio::time::sleep(Duration::from_millis(50)).await;
3076        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-9")));
3077        tokio::time::sleep(Duration::from_millis(50)).await;
3078
3079        assert!(matches!(
3080            node.resolve_peer(&live_ref).await,
3081            Err(NodeError::PeerGone(_))
3082        ));
3083        // A ref for a fully departed peer is PeerGone too — not a typo-shaped
3084        // PeerNotFound.
3085        assert!(matches!(
3086            node.resolve_peer("peer-9:99").await,
3087            Err(NodeError::PeerGone(_))
3088        ));
3089        // peer() propagates PeerGone immediately instead of waiting out the
3090        // timeout.
3091        assert!(matches!(
3092            node.peer(&live_ref, Some(2_000)).await,
3093            Err(NodeError::PeerGone(_))
3094        ));
3095    }
3096
3097    #[tokio::test]
3098    async fn test_resolve_peer_accepts_ip() {
3099        let ws_port = random_port().await;
3100        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
3101
3102        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-a")));
3103        tokio::time::sleep(Duration::from_millis(50)).await;
3104
3105        let resolved = node.resolve_peer("127.0.0.1").await.unwrap();
3106        assert_eq!(resolved.id, "peer-a");
3107        // resolve_peer_id falls back to the tailscale id when no hello
3108        // identity is populated yet.
3109        assert_eq!(node.resolve_peer_id("127.0.0.1").await.unwrap(), "peer-a");
3110    }
3111
3112    #[tokio::test]
3113    async fn test_listen_tcp_rejects_reserved_ports() {
3114        let ws_port = random_port().await;
3115        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
3116
3117        let err = node.listen_tcp(9417).await.unwrap_err();
3118        assert!(
3119            matches!(err, NodeError::ReservedPort(9417)),
3120            "expected ReservedPort(9417), got: {err}"
3121        );
3122
3123        // RFC 023 D4: 443 is no longer reserved — the guard must not reject
3124        // it. The mock binds a real host socket and 443 is privileged on
3125        // most systems, so accept either outcome; what matters is that
3126        // ReservedPort is gone and 443 bind failures carry the
3127        // sidecar-upgrade hint.
3128        match node.listen_tcp(443).await {
3129            Ok(listener) => assert_eq!(listener.port, 443),
3130            Err(NodeError::ReservedPort(_)) => panic!("443 must not be reserved (RFC 023 D4)"),
3131            Err(e) => assert!(
3132                e.to_string().contains("RFC 023"),
3133                "443 bind errors should hint at the sidecar upgrade, got: {e}"
3134            ),
3135        }
3136    }
3137
3138    #[tokio::test]
3139    async fn test_listen_quic_rejects_reserved_and_ephemeral_ports() {
3140        let ws_port = random_port().await;
3141        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
3142
3143        let err = node.listen_quic(9417).await.unwrap_err();
3144        assert!(matches!(err, NodeError::ReservedPort(9417)));
3145
3146        let err = node.listen_quic(0).await.unwrap_err();
3147        assert!(matches!(err, NodeError::NotImplemented(_)));
3148    }
3149
3150    #[test]
3151    fn test_builder_hostname_override_validation() {
3152        assert!(NodeBuilder::default().hostname("dashboard").is_ok());
3153        assert!(NodeBuilder::default().hostname("my-app-1").is_ok());
3154        for bad in ["", "Dashboard", "has.dot", "-lead", "trail-"] {
3155            assert!(
3156                NodeBuilder::default().hostname(bad).is_err(),
3157                "expected hostname {bad:?} to be rejected"
3158            );
3159        }
3160        let too_long = "x".repeat(64);
3161        assert!(NodeBuilder::default().hostname(too_long).is_err());
3162    }
3163
3164    #[tokio::test]
3165    async fn test_bind_udp_falls_back_to_direct_socket() {
3166        // The mock provider has no UDP support, so bind_udp exercises the
3167        // direct-socket fallback and must return a usable socket.
3168        let ws_port = random_port().await;
3169        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
3170
3171        let socket = node.bind_udp(0).await.unwrap();
3172        let addr = socket.local_addr().unwrap();
3173        assert_ne!(addr.port(), 0);
3174    }
3175
3176    #[test]
3177    fn test_raw_alpn_scoping() {
3178        assert_eq!(
3179            crate::transport::quic::raw_alpn("demo"),
3180            b"truffle-raw.demo".to_vec()
3181        );
3182        assert_eq!(
3183            crate::transport::quic::raw_alpn(""),
3184            b"truffle-raw".to_vec()
3185        );
3186    }
3187
3188    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3189    async fn test_connect_quic_roundtrip_via_raw_listener() {
3190        let ws_port = random_port().await;
3191        let (client_node, event_tx, _net) = make_test_node("cli", ws_port).await;
3192
3193        // Raw listener on port 0 (direct-socket fallback path) with the
3194        // ALPN that connect_quic derives from the mock app_id ("test").
3195        let server_network = Arc::new(MockNetworkProvider::new("srv"));
3196        let alpn = crate::transport::quic::raw_alpn("test");
3197        let listener = crate::transport::quic::listen_raw(&server_network, 0, &alpn)
3198            .await
3199            .unwrap();
3200        let port = listener.port();
3201        assert_ne!(port, 0);
3202
3203        let echo_task = tokio::spawn(async move {
3204            let conn = listener.accept().await.expect("no incoming connection");
3205            let mut stream = conn
3206                .accept_stream()
3207                .await
3208                .unwrap()
3209                .expect("connection closed before stream");
3210            let mut received = Vec::new();
3211            while let Some(chunk) = stream.read(1024).await.unwrap() {
3212                received.extend_from_slice(&chunk);
3213            }
3214            stream.write(&received).await.unwrap();
3215            stream.finish();
3216            // Keep the connection alive until the peer has read the echo —
3217            // closing immediately could drop in-flight stream data.
3218            tokio::time::sleep(Duration::from_millis(500)).await;
3219            conn.close();
3220        });
3221
3222        // Register the "server" as a peer at 127.0.0.1 and connect by name.
3223        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("srv-peer")));
3224        tokio::time::sleep(Duration::from_millis(50)).await;
3225
3226        let conn = client_node.connect_quic("srv-peer", port).await.unwrap();
3227        let mut stream = conn.open_stream().await.unwrap();
3228        stream.write(b"hello quic").await.unwrap();
3229        stream.finish();
3230
3231        let mut echoed = Vec::new();
3232        while let Some(chunk) = stream.read(1024).await.unwrap() {
3233            echoed.extend_from_slice(&chunk);
3234        }
3235        assert_eq!(echoed, b"hello quic");
3236
3237        echo_task.await.unwrap();
3238        conn.close();
3239    }
3240
3241    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3242    async fn test_quic_raw_alpn_mismatch_rejected() {
3243        // Cross-app connections must fail the TLS handshake outright —
3244        // ALPN is the QUIC analog of the WS hello's app_id check.
3245        let server_network = Arc::new(MockNetworkProvider::new("srv"));
3246        let listener = crate::transport::quic::listen_raw(
3247            &server_network,
3248            0,
3249            &crate::transport::quic::raw_alpn("app-a"),
3250        )
3251        .await
3252        .unwrap();
3253        let port = listener.port();
3254
3255        let client_network = Arc::new(MockNetworkProvider::new("cli"));
3256        let result = crate::transport::quic::connect_raw(
3257            &client_network,
3258            "127.0.0.1",
3259            port,
3260            &crate::transport::quic::raw_alpn("app-b"),
3261        )
3262        .await;
3263
3264        assert!(
3265            result.is_err(),
3266            "cross-app QUIC connect must fail (ALPN mismatch)"
3267        );
3268    }
3269
3270    // ── RFC 022 honest projection ─────────────────────────────────────
3271
3272    #[tokio::test]
3273    async fn test_rfc022_node_peer_resolves_and_wait_miss_returns_none() {
3274        let ws_port = random_port().await;
3275        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
3276
3277        // Miss without wait
3278        assert!(node.peer("nope", None).await.unwrap().is_none());
3279
3280        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-a")));
3281        tokio::time::sleep(Duration::from_millis(50)).await;
3282
3283        let p = node.peer("peer-a", None).await.unwrap().expect("found");
3284        assert_eq!(p.tailscale_id, "peer-a");
3285        assert!(p.device_id.is_none());
3286
3287        // waitMs on unknown times out to None
3288        let start = std::time::Instant::now();
3289        let miss = node.peer("still-missing", Some(80)).await.unwrap();
3290        assert!(miss.is_none());
3291        assert!(start.elapsed() >= Duration::from_millis(70));
3292    }
3293
3294    #[test]
3295    fn test_rfc022_peer_projection_never_uses_tailscale_as_device_id() {
3296        use crate::session::PeerState;
3297        use std::net::Ipv4Addr;
3298
3299        // Pre-identity
3300        let pre = PeerState {
3301            id: "ts-abc".into(),
3302            generation: 1,
3303            name: "truffle-app-laptop".into(),
3304            ip: Ipv4Addr::new(100, 64, 0, 1).into(),
3305            online: true,
3306            ws_connected: false,
3307            connection_type: "direct".into(),
3308            os: None,
3309            last_seen: None,
3310            identity: None,
3311            identity_suppressed: false,
3312        };
3313        let p = Peer::from(pre);
3314        assert!(p.device_id.is_none());
3315        assert_eq!(p.tailscale_id, "ts-abc");
3316        assert_eq!(p.peer_ref, "ts-abc:1");
3317        assert_eq!(p.display_name, "laptop");
3318        assert!(p
3319            .device_id
3320            .as_ref()
3321            .map(|d| d.as_str() != p.tailscale_id)
3322            .unwrap_or(true));
3323
3324        // Post-identity
3325        let post = PeerState {
3326            id: "ts-abc".into(),
3327            generation: 1,
3328            name: "truffle-app-laptop".into(),
3329            ip: Ipv4Addr::new(100, 64, 0, 1).into(),
3330            online: true,
3331            ws_connected: true,
3332            connection_type: "direct".into(),
3333            os: Some("darwin".into()),
3334            last_seen: None,
3335            identity: Some(crate::session::PeerIdentity {
3336                app_id: "app".into(),
3337                device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
3338                device_name: "Alice's Mac".into(),
3339                os: "darwin".into(),
3340                tailscale_id: "ts-abc".into(),
3341            }),
3342            identity_suppressed: false,
3343        };
3344        let p = Peer::from(post);
3345        assert_eq!(p.device_id.as_deref(), Some("01J4K9M2Z8AB3RNYQPW6H5TC0X"));
3346        assert_ne!(p.device_id.as_deref().unwrap(), p.tailscale_id);
3347        assert_eq!(p.display_name, "Alice's Mac");
3348
3349        // Suppressed (first-wins loser)
3350        let suppressed = PeerState {
3351            id: "ts-xyz".into(),
3352            generation: 1,
3353            name: "truffle-app-other".into(),
3354            ip: Ipv4Addr::new(100, 64, 0, 2).into(),
3355            online: true,
3356            ws_connected: true,
3357            connection_type: "direct".into(),
3358            os: None,
3359            last_seen: None,
3360            identity: Some(crate::session::PeerIdentity {
3361                app_id: "app".into(),
3362                device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
3363                device_name: "Clone".into(),
3364                os: "linux".into(),
3365                tailscale_id: "ts-xyz".into(),
3366            }),
3367            identity_suppressed: true,
3368        };
3369        let p = Peer::from(suppressed);
3370        assert!(
3371            p.device_id.is_none(),
3372            "suppressed claim must project null device_id"
3373        );
3374    }
3375}