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    /// Return health information from the network layer.
981    pub async fn health(&self) -> HealthInfo {
982        self.network.health().await
983    }
984
985    // ── Messaging (Layer 6 envelope over Layer 4 WS) ─────────────────────
986
987    /// Return [`NodeError::Stopped`] if [`stop`](Self::stop) has already run.
988    ///
989    /// Used by the send paths to fail fast instead of attempting a doomed
990    /// session send after teardown.
991    fn ensure_not_stopped(&self) -> Result<(), NodeError> {
992        if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
993            return Err(NodeError::Stopped);
994        }
995        Ok(())
996    }
997
998    /// Map session send errors: a stale peer-ref selector surfaces as
999    /// [`NodeError::PeerGone`] (RFC 022 I5); everything else wraps as-is.
1000    fn map_session_send_err(e: crate::session::SessionError) -> NodeError {
1001        match e {
1002            crate::session::SessionError::PeerGone(r) => NodeError::PeerGone(r),
1003            e => NodeError::Session(e),
1004        }
1005    }
1006
1007    /// Send a namespaced message to a specific peer.
1008    ///
1009    /// **Deprecated**: the wire representation depends on the *contents* of
1010    /// `data` — bytes that parse as UTF-8 JSON are sent as that JSON value
1011    /// (`b"123"` → number, `b"null"` → null), anything else becomes a JSON
1012    /// array of byte values. Use [`send_json`](Self::send_json) for
1013    /// structured payloads or [`send_bytes`](Self::send_bytes) for opaque
1014    /// binary data instead.
1015    ///
1016    /// Returns [`NodeError::Stopped`] if [`stop`](Self::stop) has been called.
1017    #[deprecated(
1018        since = "0.7.0",
1019        note = "wire type depends on data contents; use send_json or send_bytes"
1020    )]
1021    pub async fn send(&self, peer_id: &str, namespace: &str, data: &[u8]) -> Result<(), NodeError> {
1022        self.ensure_not_stopped()?;
1023        // Legacy content sniffing, kept for compatibility: if the data is
1024        // valid UTF-8 JSON, parse it into a proper JSON value so the receiver
1025        // gets a structured object rather than an array of byte values.
1026        let payload = std::str::from_utf8(data)
1027            .ok()
1028            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
1029            .unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
1030
1031        let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
1032
1033        let encoded = self.codec.encode(&envelope)?;
1034        self.session
1035            .send(peer_id, &encoded)
1036            .await
1037            .map_err(Self::map_session_send_err)?;
1038        Ok(())
1039    }
1040
1041    /// Send a JSON payload to a specific peer.
1042    ///
1043    /// The payload is wrapped in a Layer 6 [`Envelope`] with the given
1044    /// namespace and a `"message"` type — subscribers observe it unchanged
1045    /// as [`NamespacedMessage::payload`]. If no WebSocket connection
1046    /// exists, one is lazily established.
1047    pub async fn send_json(
1048        &self,
1049        peer_id: &str,
1050        namespace: &str,
1051        payload: &serde_json::Value,
1052    ) -> Result<(), NodeError> {
1053        self.send_typed(peer_id, namespace, "message", payload)
1054            .await
1055    }
1056
1057    /// Send opaque binary data to a specific peer.
1058    ///
1059    /// The bytes travel base64-encoded in a `"bytes"`-typed envelope with
1060    /// payload shape `{"encoding":"base64","data":"…"}`; receivers decode
1061    /// with [`NamespacedMessage::payload_bytes`]. Unlike the deprecated
1062    /// [`send`](Self::send), the wire representation never depends on the
1063    /// data's contents.
1064    pub async fn send_bytes(
1065        &self,
1066        peer_id: &str,
1067        namespace: &str,
1068        data: &[u8],
1069    ) -> Result<(), NodeError> {
1070        let payload = Self::bytes_payload(data);
1071        self.send_typed(peer_id, namespace, "bytes", &payload).await
1072    }
1073
1074    /// Encode opaque bytes as the `"bytes"` envelope payload.
1075    fn bytes_payload(data: &[u8]) -> serde_json::Value {
1076        use base64::Engine as _;
1077        serde_json::json!({
1078            "encoding": "base64",
1079            "data": base64::engine::general_purpose::STANDARD.encode(data),
1080        })
1081    }
1082
1083    /// Send a namespaced message with an explicit `msg_type` and JSON payload.
1084    ///
1085    /// Unlike [`send`](Self::send), this method takes a pre-built
1086    /// [`serde_json::Value`] payload and a caller-chosen `msg_type` instead
1087    /// of raw bytes with a hardcoded `"message"` type. Used by subsystems
1088    /// (file transfer, synced store, request/reply) that define their own
1089    /// wire protocol message types.
1090    pub async fn send_typed(
1091        &self,
1092        peer_id: &str,
1093        namespace: &str,
1094        msg_type: &str,
1095        payload: &serde_json::Value,
1096    ) -> Result<(), NodeError> {
1097        self.ensure_not_stopped()?;
1098        let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
1099        let encoded = self.codec.encode(&envelope)?;
1100        self.session
1101            .send(peer_id, &encoded)
1102            .await
1103            .map_err(Self::map_session_send_err)?;
1104        Ok(())
1105    }
1106
1107    /// Broadcast a namespaced message with an explicit `msg_type` and JSON
1108    /// payload to all connected peers.
1109    pub async fn broadcast_typed(
1110        &self,
1111        namespace: &str,
1112        msg_type: &str,
1113        payload: &serde_json::Value,
1114    ) {
1115        if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
1116            tracing::debug!("node: broadcast after stop ignored");
1117            return;
1118        }
1119        let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
1120        match self.codec.encode(&envelope) {
1121            Ok(encoded) => {
1122                self.session.broadcast(&encoded).await;
1123            }
1124            Err(e) => {
1125                tracing::error!("node: failed to encode broadcast envelope: {e}");
1126            }
1127        }
1128    }
1129
1130    /// Broadcast a namespaced message to all connected peers.
1131    ///
1132    /// **Deprecated**: the wire representation depends on the contents of
1133    /// `data` (see [`send`](Self::send)), and delivery failures are
1134    /// silently discarded. Use [`broadcast_json`](Self::broadcast_json) or
1135    /// [`broadcast_bytes`](Self::broadcast_bytes), which return a
1136    /// [`BroadcastReport`](crate::session::BroadcastReport).
1137    #[deprecated(
1138        since = "0.7.0",
1139        note = "wire type depends on data contents and failures are hidden; \
1140                use broadcast_json or broadcast_bytes"
1141    )]
1142    pub async fn broadcast(&self, namespace: &str, data: &[u8]) {
1143        if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
1144            tracing::debug!("node: broadcast after stop ignored");
1145            return;
1146        }
1147        // Legacy content sniffing, kept for compatibility (see send()).
1148        let payload = std::str::from_utf8(data)
1149            .ok()
1150            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
1151            .unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
1152
1153        let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
1154
1155        match self.codec.encode(&envelope) {
1156            Ok(encoded) => {
1157                self.session.broadcast(&encoded).await;
1158            }
1159            Err(e) => {
1160                tracing::error!("node: failed to encode broadcast envelope: {e}");
1161            }
1162        }
1163    }
1164
1165    /// Broadcast a JSON payload to all connected peers.
1166    ///
1167    /// Only peers with an active WebSocket connection receive the message —
1168    /// no lazy connections are established. Returns a
1169    /// [`BroadcastReport`](crate::session::BroadcastReport): "queued" means
1170    /// handed to the peer's connection task, not confirmed delivery.
1171    pub async fn broadcast_json(
1172        &self,
1173        namespace: &str,
1174        payload: &serde_json::Value,
1175    ) -> Result<crate::session::BroadcastReport, NodeError> {
1176        self.broadcast_reported(namespace, "message", payload).await
1177    }
1178
1179    /// Broadcast opaque binary data to all connected peers.
1180    ///
1181    /// Same wire shape as [`send_bytes`](Self::send_bytes); same report
1182    /// semantics as [`broadcast_json`](Self::broadcast_json).
1183    pub async fn broadcast_bytes(
1184        &self,
1185        namespace: &str,
1186        data: &[u8],
1187    ) -> Result<crate::session::BroadcastReport, NodeError> {
1188        let payload = Self::bytes_payload(data);
1189        self.broadcast_reported(namespace, "bytes", &payload).await
1190    }
1191
1192    /// Shared broadcast implementation: encode once, report the outcome.
1193    /// Unlike the deprecated fire-and-forget path, encode failures and
1194    /// stopped-node calls surface as errors.
1195    async fn broadcast_reported(
1196        &self,
1197        namespace: &str,
1198        msg_type: &str,
1199        payload: &serde_json::Value,
1200    ) -> Result<crate::session::BroadcastReport, NodeError> {
1201        self.ensure_not_stopped()?;
1202        let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
1203        let encoded = self.codec.encode(&envelope)?;
1204        Ok(self.session.broadcast(&encoded).await)
1205    }
1206
1207    /// Subscribe to messages in a specific namespace.
1208    ///
1209    /// Returns a broadcast receiver that yields [`NamespacedMessage`]s
1210    /// matching the given namespace. Multiple subscribers to the same
1211    /// namespace share the same underlying channel.
1212    pub fn subscribe(&self, namespace: &str) -> broadcast::Receiver<NamespacedMessage> {
1213        // Fast path: check if subscriber already exists (read lock).
1214        // Poisoning is recovered (into_inner), not propagated — see the
1215        // envelope router for the rationale.
1216        {
1217            let filters = self
1218                .namespace_filters
1219                .read()
1220                .unwrap_or_else(std::sync::PoisonError::into_inner);
1221            if let Some(tx) = filters.get(namespace) {
1222                return tx.subscribe();
1223            }
1224        }
1225
1226        // Slow path: create a new channel for this namespace (write lock).
1227        let mut filters = self
1228            .namespace_filters
1229            .write()
1230            .unwrap_or_else(std::sync::PoisonError::into_inner);
1231        // Double-check after acquiring write lock.
1232        if let Some(tx) = filters.get(namespace) {
1233            return tx.subscribe();
1234        }
1235        // Opportunistic sweep: drop entries whose receivers are all gone.
1236        // The router prunes on send failure, but only for namespaces that
1237        // still receive traffic — this catches the silent ones.
1238        filters.retain(|_, tx| tx.receiver_count() > 0);
1239        let (tx, rx) = broadcast::channel(256);
1240        filters.insert(namespace.to_string(), tx);
1241        rx
1242    }
1243
1244    // ── Raw streams (Layer 4 direct) ─────────────────────────────────────
1245
1246    /// Open a raw TCP stream to a peer on the given port.
1247    ///
1248    /// Resolves the peer ID to an IP address via the session's peer list,
1249    /// then dials via the network layer. Accepts the same identifier
1250    /// forms as [`resolve_peer_id`](Self::resolve_peer_id). Returns a
1251    /// plain `TcpStream` for byte-oriented I/O.
1252    ///
1253    /// The stream is raw even on port 443 — the sidecar's legacy auto-TLS
1254    /// wrap for 443 dials is disabled on this path.
1255    pub async fn open_tcp(&self, peer_id: &str, port: u16) -> Result<TcpStream, NodeError> {
1256        self.ensure_not_stopped()?;
1257        let peer = self.resolve_peer(peer_id).await?;
1258        let addr = peer.ip.to_string();
1259        self.network
1260            .dial_tcp_opts(&addr, port, DialOpts { tls: Some(false) })
1261            .await
1262            .map_err(|e| NodeError::ConnectionFailed(e.to_string()))
1263    }
1264
1265    /// Listen for incoming TCP connections on a port.
1266    ///
1267    /// Returns a [`RawListener`] that yields raw `TcpStream`s. The caller
1268    /// is responsible for accepting connections in a loop. Port 0 binds an
1269    /// ephemeral port (advertise the resolved `RawListener::port` in-band,
1270    /// like the file transfer subsystem does). The session WebSocket port
1271    /// (default 9417) is reserved.
1272    pub async fn listen_tcp(&self, port: u16) -> Result<RawListener, NodeError> {
1273        self.listen_tcp_opts(port, ListenOpts::default()).await
1274    }
1275
1276    /// As [`listen_tcp`](Self::listen_tcp), with options (RFC 023 §7.1).
1277    ///
1278    /// `tls: true` terminates TLS in the sidecar with automatic MagicDNS
1279    /// certificates (requires MagicDNS + HTTPS enabled on the tailnet);
1280    /// accepted streams then carry plaintext HTTP over the loopback bridge.
1281    pub async fn listen_tcp_opts(
1282        &self,
1283        port: u16,
1284        opts: ListenOpts,
1285    ) -> Result<RawListener, NodeError> {
1286        self.ensure_not_stopped()?;
1287        use crate::transport::tcp::TcpTransport;
1288
1289        ensure_port_unreserved(port, self.ws_port)?;
1290        let tcp = TcpTransport::new(self.network.clone());
1291        tcp.listen_opts(port, opts).await.map_err(|e| {
1292            // RFC 023 unreserved 443, but sidecars predating it still bind a
1293            // legacy TLS listener there — a double-bind here is almost
1294            // always that, so say so instead of a bare LISTEN_ERROR.
1295            if port == 443 {
1296                NodeError::ConnectionFailed(format!(
1297                    "{e} (port 443 requires a sidecar built with RFC 023 — older sidecars bind \
1298                     a legacy TLS listener on 443; upgrade the sidecar binary)"
1299                ))
1300            } else {
1301                NodeError::Transport(e)
1302            }
1303        })
1304    }
1305
1306    /// Stop listening on a previously opened TCP port.
1307    ///
1308    /// Dropping the [`RawListener`] alone stops local delivery but leaves
1309    /// the tsnet port bound in the sidecar; this releases it.
1310    pub async fn unlisten_tcp(&self, port: u16) -> Result<(), NodeError> {
1311        self.ensure_not_stopped()?;
1312        self.network
1313            .unlisten_tcp(port)
1314            .await
1315            .map_err(NodeError::Network)
1316    }
1317
1318    /// Open a raw QUIC connection to a peer on the given port.
1319    ///
1320    /// The connection carries multiple concurrent bidirectional byte
1321    /// streams ([`QuicConnection::open_stream`]) with no head-of-line
1322    /// blocking between them. App scoping is enforced at the TLS layer via
1323    /// ALPN (`truffle-raw.{app_id}`) — peers from a different app fail the
1324    /// handshake. Accepts the same identifier forms as
1325    /// [`resolve_peer_id`](Self::resolve_peer_id).
1326    pub async fn connect_quic(
1327        &self,
1328        peer_id: &str,
1329        port: u16,
1330    ) -> Result<QuicConnection, NodeError> {
1331        self.ensure_not_stopped()?;
1332        let peer = self.resolve_peer(peer_id).await?;
1333        let alpn = crate::transport::quic::raw_alpn(&self.network.local_identity().app_id);
1334        crate::transport::quic::connect_raw(&self.network, &peer.ip.to_string(), port, &alpn)
1335            .await
1336            .map_err(NodeError::Transport)
1337    }
1338
1339    /// Listen for raw QUIC connections on a port.
1340    ///
1341    /// Returns a [`QuicListener`] that yields [`QuicConnection`]s. Only
1342    /// same-app peers can complete the handshake (ALPN scoping). The
1343    /// session WebSocket port (default 9417) is reserved, and port 0 is
1344    /// not yet supported over the tsnet relay (the relay cannot report the
1345    /// actual ephemeral port back).
1346    pub async fn listen_quic(&self, port: u16) -> Result<QuicListener, NodeError> {
1347        self.ensure_not_stopped()?;
1348        ensure_port_unreserved(port, self.ws_port)?;
1349        if port == 0 {
1350            return Err(NodeError::NotImplemented(
1351                "ephemeral (port 0) QUIC listeners are not supported over the tsnet relay yet — choose an explicit port"
1352                    .to_string(),
1353            ));
1354        }
1355        let alpn = crate::transport::quic::raw_alpn(&self.network.local_identity().app_id);
1356        crate::transport::quic::listen_raw(&self.network, port, &alpn)
1357            .await
1358            .map_err(NodeError::Transport)
1359    }
1360
1361    /// Bind a UDP datagram socket on a port.
1362    ///
1363    /// Datagrams are relayed through the network provider (tsnet) with
1364    /// boundaries preserved; the transport falls back to a direct host
1365    /// socket only when the provider has no UDP support (tests). Returns a
1366    /// [`DatagramSocket`] supporting `send_to` / `recv_from` with tailnet
1367    /// addresses. IPv4 (`100.x`) peers only; keep payloads ≤ ~1200 bytes
1368    /// to stay under the tailnet MTU. Port 0 binds an ephemeral relay
1369    /// port — suitable for client-style sockets that send first.
1370    pub async fn bind_udp(&self, port: u16) -> Result<DatagramSocket, NodeError> {
1371        self.ensure_not_stopped()?;
1372        use crate::transport::udp::{UdpConfig, UdpTransport};
1373        use crate::transport::DatagramTransport;
1374
1375        let udp = UdpTransport::new(self.network.clone(), UdpConfig::default());
1376        udp.bind(port).await.map_err(NodeError::Transport)
1377    }
1378}
1379
1380impl<N: NetworkProvider + 'static> Drop for Node<N> {
1381    fn drop(&mut self) {
1382        // Insurance for nodes dropped without stop(): cancelling is cheap,
1383        // synchronous, and lets background tasks exit instead of idling on
1384        // channels that may never close.
1385        self.tasks.cancel.cancel();
1386    }
1387}
1388
1389/// The bare device-name slug from a Layer 3 hostname, when it follows this
1390/// app's `truffle-{app_id}-{slug}` convention (RFC 017). `None` otherwise.
1391fn hostname_slug<'a>(hostname: &'a str, app_id: &str) -> Option<&'a str> {
1392    hostname
1393        .strip_prefix("truffle-")?
1394        .strip_prefix(app_id)?
1395        .strip_prefix('-')
1396}
1397
1398/// A valid single DNS label: 1–63 chars of `[a-z0-9-]`, lowercase, no
1399/// leading/trailing hyphen, no dots (tsnet takes a bare hostname, not an
1400/// FQDN). Used by [`NodeBuilder::hostname`].
1401fn validate_hostname_label(s: &str) -> Result<(), String> {
1402    let ok_len = (1..=63).contains(&s.len());
1403    let ok_chars = s
1404        .chars()
1405        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
1406    let ok_edges = !s.starts_with('-') && !s.ends_with('-');
1407    if ok_len && ok_chars && ok_edges {
1408        Ok(())
1409    } else {
1410        Err(format!(
1411            "invalid hostname {s:?}: must be a single lowercase DNS label \
1412             (1-63 chars of [a-z0-9-], no leading/trailing hyphen, no dots)"
1413        ))
1414    }
1415}
1416
1417/// Reject ports reserved by truffle's own listeners: the node's configured
1418/// session WebSocket port (default 9417). Port 443 is deliberately NOT
1419/// reserved anymore — RFC 023 removed the sidecar's legacy TLS listener so
1420/// users can serve HTTPS on the default port.
1421pub(crate) fn ensure_port_unreserved(port: u16, ws_port: u16) -> Result<(), NodeError> {
1422    if port == ws_port {
1423        Err(NodeError::ReservedPort(port))
1424    } else {
1425        Ok(())
1426    }
1427}
1428
1429// ---------------------------------------------------------------------------
1430// NodeBuilder
1431// ---------------------------------------------------------------------------
1432
1433/// Builder for constructing a [`Node<TailscaleProvider>`].
1434///
1435/// Configures the Tailscale sidecar, RFC 017 identity, and transport
1436/// parameters before wiring all layers together.
1437///
1438/// # Example
1439///
1440/// ```ignore
1441/// let node = Node::builder()
1442///     .app_id("playground")?
1443///     .device_name("alice-mbp")
1444///     .sidecar_path("/opt/truffle/sidecar")
1445///     .ws_port(9417)
1446///     .build()
1447///     .await?;
1448/// ```
1449#[derive(Clone)]
1450pub struct NodeBuilder {
1451    app_id: Option<AppId>,
1452    device_name: Option<DeviceName>,
1453    device_id: Option<DeviceId>,
1454    /// RFC 023 §6.4: explicit Tailscale hostname, bypassing the
1455    /// `truffle-{app_id}-{slug}` convention (pretty serving URLs).
1456    hostname: Option<String>,
1457    sidecar_path: Option<PathBuf>,
1458    state_dir: Option<String>,
1459    auth_key: Option<String>,
1460    ephemeral: bool,
1461    ws_port: u16,
1462    idle_timeout_secs: Option<u64>,
1463    /// RFC 022 Phase C: proactively exchange hello with online peers.
1464    eager_identity: bool,
1465}
1466
1467/// Manual `Debug`: `auth_key` is a tailnet credential and must never reach
1468/// logs, so it is redacted while preserving presence (`Some`/`None`).
1469impl std::fmt::Debug for NodeBuilder {
1470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1471        f.debug_struct("NodeBuilder")
1472            .field("app_id", &self.app_id)
1473            .field("device_name", &self.device_name)
1474            .field("device_id", &self.device_id)
1475            .field("hostname", &self.hostname)
1476            .field("sidecar_path", &self.sidecar_path)
1477            .field("state_dir", &self.state_dir)
1478            .field("auth_key", &self.auth_key.as_ref().map(|_| "[REDACTED]"))
1479            .field("ephemeral", &self.ephemeral)
1480            .field("ws_port", &self.ws_port)
1481            .field("idle_timeout_secs", &self.idle_timeout_secs)
1482            .field("eager_identity", &self.eager_identity)
1483            .finish()
1484    }
1485}
1486
1487/// Atomically and durably write a string to `path` by writing to a sibling
1488/// `.tmp` file first and then renaming it over the destination.
1489///
1490/// The caller persists the durable device ULID (RFC 017 §5.4), so a crash
1491/// mid-write must never lose or truncate an existing identity file:
1492/// - the temp file is fsynced before the rename publishes it, so the
1493///   destination can never be observed empty or truncated;
1494/// - on POSIX, `rename(2)` atomically replaces the destination and the
1495///   parent directory is fsynced so the rename itself survives power loss;
1496/// - on Windows, `MoveFileExW(REPLACE_EXISTING | WRITE_THROUGH)` replaces
1497///   the destination without the delete-then-rename gap that could drop
1498///   the file entirely if the process died between the two steps.
1499fn atomic_write_string(path: &Path, content: &str) -> std::io::Result<()> {
1500    let parent = path.parent().ok_or_else(|| {
1501        std::io::Error::new(
1502            std::io::ErrorKind::InvalidInput,
1503            "path has no parent directory",
1504        )
1505    })?;
1506    let mut tmp = path.to_path_buf();
1507    tmp.set_extension("tmp");
1508    {
1509        use std::io::Write as _;
1510        let mut f = std::fs::File::create(&tmp)?;
1511        f.write_all(content.as_bytes())?;
1512        f.sync_all()?;
1513    }
1514    #[cfg(unix)]
1515    {
1516        std::fs::rename(&tmp, path)?;
1517        // Best-effort: some filesystems refuse opening a directory for
1518        // fsync; the rename is still atomic without it, just not yet
1519        // guaranteed on disk.
1520        if let Ok(dir) = std::fs::File::open(parent) {
1521            let _ = dir.sync_all();
1522        }
1523    }
1524    #[cfg(windows)]
1525    {
1526        let _ = parent; // only needed for the unix dir-fsync path
1527        replace_file_windows(&tmp, path)?;
1528    }
1529    Ok(())
1530}
1531
1532/// Replace `dest` with `tmp` in one step via `MoveFileExW`.
1533///
1534/// `MOVEFILE_REPLACE_EXISTING` avoids the non-atomic remove-then-rename
1535/// dance (`std::fs::rename` errors on an existing destination on Windows);
1536/// `MOVEFILE_WRITE_THROUGH` blocks until the move is flushed to disk.
1537#[cfg(windows)]
1538fn replace_file_windows(tmp: &Path, dest: &Path) -> std::io::Result<()> {
1539    use std::os::windows::ffi::OsStrExt;
1540
1541    const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
1542    const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
1543
1544    #[link(name = "kernel32")]
1545    extern "system" {
1546        fn MoveFileExW(from: *const u16, to: *const u16, flags: u32) -> i32;
1547    }
1548
1549    fn wide(p: &Path) -> Vec<u16> {
1550        p.as_os_str()
1551            .encode_wide()
1552            .chain(std::iter::once(0))
1553            .collect()
1554    }
1555
1556    let (from, to) = (wide(tmp), wide(dest));
1557    let ok = unsafe {
1558        MoveFileExW(
1559            from.as_ptr(),
1560            to.as_ptr(),
1561            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
1562        )
1563    };
1564    if ok == 0 {
1565        return Err(std::io::Error::last_os_error());
1566    }
1567    Ok(())
1568}
1569
1570impl Default for NodeBuilder {
1571    fn default() -> Self {
1572        Self {
1573            app_id: None,
1574            device_name: None,
1575            device_id: None,
1576            sidecar_path: None,
1577            state_dir: None,
1578            auth_key: None,
1579            hostname: None,
1580            ephemeral: false,
1581            ws_port: 9417,
1582            idle_timeout_secs: None,
1583            eager_identity: true,
1584        }
1585    }
1586}
1587
1588impl NodeBuilder {
1589    /// Set the application namespace identifier (RFC 017 §5.1).
1590    ///
1591    /// The input is validated against `^[a-z][a-z0-9-]{1,31}$`. Invalid
1592    /// values are rejected with `NodeError::BuildError`.
1593    pub fn app_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
1594        let raw: String = s.into();
1595        let app_id = AppId::parse(&raw)
1596            .map_err(|e| NodeError::BuildError(format!("invalid app_id: {e}")))?;
1597        self.app_id = Some(app_id);
1598        Ok(self)
1599    }
1600
1601    /// Set the human-readable device name.
1602    ///
1603    /// Accepts any Unicode input; soft-truncated to 256 graphemes. When
1604    /// unset, the builder falls back to `hostname::get()` at `build()` time.
1605    pub fn device_name(mut self, s: impl Into<String>) -> Self {
1606        self.device_name = Some(DeviceName::parse(s));
1607        self
1608    }
1609
1610    /// Override the composed Tailscale hostname (RFC 023 §6.4).
1611    ///
1612    /// Bypasses the `truffle-{app_id}-{slug(device_name)}` convention so
1613    /// serving URLs read like a product (`https://dashboard.{tailnet}.ts.net`).
1614    /// Validated as a single lowercase DNS label (1–63 chars of `[a-z0-9-]`,
1615    /// no leading/trailing hyphen, no dots — tsnet takes a bare hostname).
1616    ///
1617    /// Tradeoff: hello-less peers with a custom hostname are not resolvable
1618    /// by bare device name (the hostname-slug convention no longer matches);
1619    /// full hostname, dnsName, IP, device-id, and post-hello identity
1620    /// resolution are unaffected. Tailscale dedupes hostname collisions by
1621    /// suffixing `-1`/`-2` — read the granted name from
1622    /// `local_info().dns_name` instead of string-building URLs.
1623    pub fn hostname(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
1624        let raw: String = s.into();
1625        validate_hostname_label(&raw).map_err(NodeError::BuildError)?;
1626        self.hostname = Some(raw);
1627        Ok(self)
1628    }
1629
1630    /// Override the auto-generated device ID.
1631    ///
1632    /// Validates that `s` is a well-formed ULID. When provided, the value
1633    /// is persisted to `{state_dir}/device-id.txt` during `build()` so
1634    /// subsequent starts without an explicit `device_id` see it.
1635    pub fn device_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
1636        let raw: String = s.into();
1637        let device_id = DeviceId::parse(&raw)
1638            .map_err(|e| NodeError::BuildError(format!("invalid device_id: {e}")))?;
1639        self.device_id = Some(device_id);
1640        Ok(self)
1641    }
1642
1643    /// Set the path to the Go sidecar binary.
1644    pub fn sidecar_path(mut self, path: impl Into<PathBuf>) -> Self {
1645        self.sidecar_path = Some(path.into());
1646        self
1647    }
1648
1649    /// Set the Tailscale state directory.
1650    pub fn state_dir(mut self, dir: &str) -> Self {
1651        self.state_dir = Some(dir.to_string());
1652        self
1653    }
1654
1655    /// Set the Tailscale auth key for headless authentication.
1656    pub fn auth_key(mut self, key: &str) -> Self {
1657        self.auth_key = Some(key.to_string());
1658        self
1659    }
1660
1661    /// Set whether the node is ephemeral (auto-removed from tailnet on shutdown).
1662    pub fn ephemeral(mut self, val: bool) -> Self {
1663        self.ephemeral = val;
1664        self
1665    }
1666
1667    /// Set the WebSocket listen port.
1668    /// RFC 022 Phase C: when true (default), proactively exchange hello with
1669    /// online peers so durable `device_id` is learned without app `send`.
1670    pub fn eager_identity(mut self, enabled: bool) -> Self {
1671        self.eager_identity = enabled;
1672        self
1673    }
1674
1675    pub fn ws_port(mut self, port: u16) -> Self {
1676        self.ws_port = port;
1677        self
1678    }
1679
1680    /// Set the idle timeout (in seconds) for bridged raw TCP connections.
1681    ///
1682    /// The sidecar reaps quiet bridged connections after this long
1683    /// (default: 600 s). Apps holding long-lived quiet sockets should
1684    /// raise this or send application-level keepalives.
1685    pub fn idle_timeout_secs(mut self, secs: u64) -> Self {
1686        self.idle_timeout_secs = Some(secs);
1687        self
1688    }
1689
1690    /// Resolve RFC 017 identity values and the Tailscale config.
1691    ///
1692    /// Shared between [`build()`](Self::build) and
1693    /// [`build_with_auth_handler()`](Self::build_with_auth_handler). Returns
1694    /// the ready-to-start `TailscaleConfig` along with the parsed identity
1695    /// triple.
1696    fn prepare_config(&self) -> Result<TailscaleConfig, NodeError> {
1697        // 1. sidecar binary is required.
1698        let binary_path = self
1699            .sidecar_path
1700            .clone()
1701            .ok_or_else(|| NodeError::BuildError("sidecar_path is required".into()))?;
1702
1703        // 2. app_id is required.
1704        let app_id = self
1705            .app_id
1706            .clone()
1707            .ok_or_else(|| NodeError::BuildError("app_id is required".into()))?;
1708
1709        // 3. device_name falls back to the OS hostname.
1710        let device_name = match self.device_name.clone() {
1711            Some(name) => name,
1712            None => {
1713                let os_hostname = hostname::get()
1714                    .map_err(|e| {
1715                        NodeError::BuildError(format!(
1716                            "device_name is unset and hostname::get() failed: {e}"
1717                        ))
1718                    })?
1719                    .to_string_lossy()
1720                    .into_owned();
1721                DeviceName::parse(os_hostname)
1722            }
1723        };
1724
1725        // 4. Compose the Tailscale hostname once, here. Downstream code
1726        //    MUST NOT rebuild it — the provider config stores this verbatim.
1727        //    An explicit builder hostname (RFC 023 §6.4) wins over the
1728        //    `truffle-{app_id}-{slug}` convention; see the setter for what
1729        //    that costs (bare-name resolution of hello-less peers).
1730        let tailscale_host = match self.hostname.clone() {
1731            Some(hostname) => hostname,
1732            None => identity::tailscale_hostname(&app_id, &device_name),
1733        };
1734
1735        // 5. Resolve state_dir. Default:
1736        //    `{dirs::data_dir}/truffle/{app_id}/{slug(device_name)}`.
1737        //    No temp-dir fallback: state_dir holds the durable device ULID
1738        //    and tsnet keys, and a temp directory silently resets both on
1739        //    reboot. Platforms without a data dir must opt in explicitly.
1740        let state_dir = match self.state_dir.clone() {
1741            Some(dir) => dir,
1742            None => {
1743                let base = dirs::data_dir().ok_or_else(|| {
1744                    NodeError::BuildError(
1745                        "no platform data directory available (dirs::data_dir() returned None); \
1746                         set state_dir explicitly to a durable location"
1747                            .into(),
1748                    )
1749                })?;
1750                base.join("truffle")
1751                    .join(app_id.as_str())
1752                    .join(identity::slug(device_name.as_str(), 255))
1753                    .to_string_lossy()
1754                    .into_owned()
1755            }
1756        };
1757
1758        // 6. Ensure the state directory exists before Tailscale starts.
1759        std::fs::create_dir_all(&state_dir)?;
1760
1761        // 7. Resolve device_id. Priority:
1762        //    a) explicit builder override → validate + persist
1763        //    b) existing `device-id.txt` → read + validate
1764        //    c) generate + persist
1765        let device_id_file = Path::new(&state_dir).join("device-id.txt");
1766        let device_id = match self.device_id.clone() {
1767            Some(id) => {
1768                // Persist the override so later auto-generated calls see it.
1769                atomic_write_string(&device_id_file, id.as_str())?;
1770                id
1771            }
1772            None => {
1773                if device_id_file.exists() {
1774                    let s = std::fs::read_to_string(&device_id_file)?.trim().to_string();
1775                    DeviceId::parse(&s).map_err(|e| {
1776                        NodeError::BuildError(format!(
1777                            "device-id.txt at {device_id_file:?} contains an invalid ULID: {e}"
1778                        ))
1779                    })?
1780                } else {
1781                    let id = DeviceId::generate();
1782                    atomic_write_string(&device_id_file, id.as_str())?;
1783                    id
1784                }
1785            }
1786        };
1787
1788        Ok(TailscaleConfig {
1789            binary_path,
1790            app_id: app_id.as_str().to_string(),
1791            device_id: device_id.as_str().to_string(),
1792            device_name: device_name.as_str().to_string(),
1793            hostname: tailscale_host,
1794            state_dir,
1795            auth_key: self.auth_key.clone(),
1796            ephemeral: if self.ephemeral { Some(true) } else { None },
1797            tags: None,
1798            idle_timeout_secs: self.idle_timeout_secs,
1799        })
1800    }
1801
1802    /// Build and start the node.
1803    ///
1804    /// This creates the TailscaleProvider, starts it, creates the WebSocket
1805    /// transport and PeerRegistry, starts the session, and spawns the
1806    /// envelope router.
1807    ///
1808    /// # Errors
1809    ///
1810    /// Returns [`NodeError::BuildError`] if required configuration is missing,
1811    /// or propagates errors from the network provider startup.
1812    pub async fn build(self) -> Result<Node<TailscaleProvider>, NodeError> {
1813        let ws_port = self.ws_port;
1814        let eager_identity = self.eager_identity;
1815        let config = self.prepare_config()?;
1816        let state_dir = PathBuf::from(&config.state_dir);
1817
1818        let mut provider = TailscaleProvider::new(config);
1819        provider.start().await.map_err(NodeError::Network)?;
1820
1821        let network = Arc::new(provider);
1822
1823        // 2. Create WebSocket transport.
1824        let ws_config = WsConfig {
1825            port: ws_port,
1826            ..Default::default()
1827        };
1828        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
1829
1830        // 3. Create PeerRegistry and start session.
1831        let session = Arc::new(PeerRegistry::with_options(
1832            network.clone(),
1833            ws_transport,
1834            crate::session::PeerRegistryOptions {
1835                eager_identity,
1836                ..Default::default()
1837            },
1838        ));
1839        session.start().await;
1840
1841        // 4. Create the node with the envelope router.
1842        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
1843        let node = Node::from_parts(network, session, codec)
1844            .with_state_dir(state_dir)
1845            .with_ws_port(ws_port);
1846
1847        tracing::info!("node: started successfully");
1848        Ok(node)
1849    }
1850
1851    /// Build and start the node, calling `on_auth` if authentication is needed.
1852    ///
1853    /// This is identical to [`build()`](Self::build) except it subscribes to
1854    /// provider events *before* `provider.start()` blocks, forwarding
1855    /// `AuthRequired` events to the callback while waiting for authentication
1856    /// to complete.
1857    ///
1858    /// # Errors
1859    ///
1860    /// Returns [`NodeError::BuildError`] if required configuration is missing,
1861    /// or propagates errors from the network provider startup.
1862    pub async fn build_with_auth_handler(
1863        self,
1864        on_auth: impl Fn(String) + Send + 'static,
1865    ) -> Result<Node<TailscaleProvider>, NodeError> {
1866        let ws_port = self.ws_port;
1867        let eager_identity = self.eager_identity;
1868        let config = self.prepare_config()?;
1869        let state_dir = PathBuf::from(&config.state_dir);
1870
1871        let mut provider = TailscaleProvider::new(config);
1872
1873        // 2. Subscribe to peer events BEFORE start() so we capture auth URLs.
1874        let mut auth_rx = provider.peer_events();
1875
1876        // 3. Spawn a task that forwards AuthRequired events to the callback.
1877        let auth_task = tokio::spawn(async move {
1878            use crate::network::NetworkPeerEvent;
1879            loop {
1880                match auth_rx.recv().await {
1881                    Ok(NetworkPeerEvent::AuthRequired { url }) => {
1882                        on_auth(url);
1883                    }
1884                    Err(broadcast::error::RecvError::Closed) => break,
1885                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
1886                    _ => {} // Ignore other events
1887                }
1888            }
1889        });
1890
1891        // 4. Start the provider (blocks until auth completes).
1892        let start_result = provider.start().await.map_err(NodeError::Network);
1893
1894        // 5. Cancel the auth forwarding task — auth is done.
1895        auth_task.abort();
1896
1897        start_result?;
1898
1899        let network = Arc::new(provider);
1900
1901        // 6. Create WebSocket transport.
1902        let ws_config = WsConfig {
1903            port: ws_port,
1904            ..Default::default()
1905        };
1906        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
1907
1908        // 7. Create PeerRegistry and start session.
1909        let session = Arc::new(PeerRegistry::with_options(
1910            network.clone(),
1911            ws_transport,
1912            crate::session::PeerRegistryOptions {
1913                eager_identity,
1914                ..Default::default()
1915            },
1916        ));
1917        session.start().await;
1918
1919        // 8. Create the node with the envelope router.
1920        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
1921        let node = Node::from_parts(network, session, codec)
1922            .with_state_dir(state_dir)
1923            .with_ws_port(ws_port);
1924
1925        tracing::info!("node: started successfully (with auth handler)");
1926        Ok(node)
1927    }
1928}
1929
1930// ---------------------------------------------------------------------------
1931// Tests
1932// ---------------------------------------------------------------------------
1933
1934#[cfg(test)]
1935mod tests {
1936    use super::*;
1937    use crate::network::{
1938        HealthInfo, IncomingConnection, NetworkError, NetworkPeer, NetworkPeerEvent,
1939        NetworkTcpListener, NetworkUdpSocket, PeerAddr,
1940    };
1941    use crate::transport::WsConfig;
1942    use serde_json::json;
1943    use std::sync::atomic::{AtomicUsize, Ordering};
1944    use std::time::Duration;
1945    use tokio::sync::{broadcast, mpsc};
1946
1947    #[test]
1948    fn node_builder_debug_redacts_auth_key() {
1949        let builder = NodeBuilder::default().auth_key("dummy-auth-SECRET123");
1950        let dbg = format!("{builder:?}");
1951        assert!(!dbg.contains("SECRET123"));
1952        assert!(dbg.contains("[REDACTED]"));
1953    }
1954
1955    #[test]
1956    fn atomic_write_string_creates_and_replaces() {
1957        let dir =
1958            std::env::temp_dir().join(format!("truffle-atomic-write-test-{}", std::process::id()));
1959        std::fs::create_dir_all(&dir).unwrap();
1960        let path = dir.join("device-id.txt");
1961
1962        atomic_write_string(&path, "01AAAAAAAAAAAAAAAAAAAAAAAA").unwrap();
1963        assert_eq!(
1964            std::fs::read_to_string(&path).unwrap(),
1965            "01AAAAAAAAAAAAAAAAAAAAAAAA"
1966        );
1967
1968        // Replacing an existing destination must succeed on every platform
1969        // (exercises the MoveFileExW REPLACE_EXISTING path on Windows CI).
1970        atomic_write_string(&path, "01BBBBBBBBBBBBBBBBBBBBBBBB").unwrap();
1971        assert_eq!(
1972            std::fs::read_to_string(&path).unwrap(),
1973            "01BBBBBBBBBBBBBBBBBBBBBBBB"
1974        );
1975
1976        std::fs::remove_dir_all(&dir).ok();
1977    }
1978
1979    // ── Mock NetworkProvider ──────────────────────────────────────────
1980
1981    struct MockNetworkProvider {
1982        identity: NodeIdentity,
1983        local_addr: PeerAddr,
1984        peer_event_tx: broadcast::Sender<NetworkPeerEvent>,
1985        /// Pre-loaded peer list for `peers()`.
1986        mock_peers: Arc<RwLock<Vec<NetworkPeer>>>,
1987        /// Count of `stop()` invocations — lets tests assert the Node
1988        /// actually shut the provider down during teardown.
1989        stop_calls: Arc<AtomicUsize>,
1990    }
1991
1992    impl MockNetworkProvider {
1993        fn new(id: &str) -> Self {
1994            // RFC 017: align `device_id` with fixture input so tests can
1995            // reason about a single identifier.
1996            Self::new_with_device_id(id, id)
1997        }
1998
1999        /// `device_id` distinct from the tailscale id — required when a test
2000        /// lets the node hello with itself (loopback self-dial): publishing
2001        /// an identity whose device_id equals the entry's tailscale_id would
2002        /// violate RFC 022 invariant I1.
2003        fn new_with_device_id(id: &str, device_id: &str) -> Self {
2004            let (peer_event_tx, _) = broadcast::channel(64);
2005            Self {
2006                identity: NodeIdentity {
2007                    app_id: "test".to_string(),
2008                    device_id: device_id.to_string(),
2009                    device_name: format!("Test Node {id}"),
2010                    tailscale_hostname: format!("truffle-test-{id}"),
2011                    tailscale_id: id.to_string(),
2012                    dns_name: None,
2013                    ip: Some("127.0.0.1".parse().unwrap()),
2014                },
2015                local_addr: PeerAddr {
2016                    ip: Some("127.0.0.1".parse().unwrap()),
2017                    hostname: format!("truffle-test-{id}"),
2018                    dns_name: None,
2019                },
2020                peer_event_tx,
2021                mock_peers: Arc::new(RwLock::new(Vec::new())),
2022                stop_calls: Arc::new(AtomicUsize::new(0)),
2023            }
2024        }
2025
2026        fn event_sender(&self) -> broadcast::Sender<NetworkPeerEvent> {
2027            self.peer_event_tx.clone()
2028        }
2029
2030        /// Number of times `stop()` has been called on this provider.
2031        fn stop_call_count(&self) -> usize {
2032            self.stop_calls.load(Ordering::SeqCst)
2033        }
2034    }
2035
2036    impl NetworkProvider for MockNetworkProvider {
2037        async fn start(&mut self) -> Result<(), NetworkError> {
2038            Ok(())
2039        }
2040
2041        async fn stop(&self) -> Result<(), NetworkError> {
2042            self.stop_calls.fetch_add(1, Ordering::SeqCst);
2043            Ok(())
2044        }
2045
2046        fn local_identity(&self) -> NodeIdentity {
2047            self.identity.clone()
2048        }
2049
2050        fn local_addr(&self) -> PeerAddr {
2051            self.local_addr.clone()
2052        }
2053
2054        fn peer_events(&self) -> broadcast::Receiver<NetworkPeerEvent> {
2055            self.peer_event_tx.subscribe()
2056        }
2057
2058        async fn peers(&self) -> Vec<NetworkPeer> {
2059            self.mock_peers.read().await.clone()
2060        }
2061
2062        async fn dial_tcp(&self, addr: &str, port: u16) -> Result<TcpStream, NetworkError> {
2063            let target = format!("{addr}:{port}");
2064            TcpStream::connect(&target)
2065                .await
2066                .map_err(|e| NetworkError::DialFailed(format!("mock dial {target}: {e}")))
2067        }
2068
2069        async fn listen_tcp(&self, port: u16) -> Result<NetworkTcpListener, NetworkError> {
2070            let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
2071                .await
2072                .map_err(|e| NetworkError::ListenFailed(format!("mock listen :{port}: {e}")))?;
2073
2074            let actual_port = listener.local_addr().unwrap().port();
2075            let (tx, rx) = mpsc::channel::<IncomingConnection>(64);
2076
2077            tokio::spawn(async move {
2078                loop {
2079                    match listener.accept().await {
2080                        Ok((stream, addr)) => {
2081                            let conn = IncomingConnection {
2082                                stream,
2083                                remote_addr: addr.to_string(),
2084                                remote_identity: String::new(),
2085                                port: actual_port,
2086                            };
2087                            if tx.send(conn).await.is_err() {
2088                                break;
2089                            }
2090                        }
2091                        Err(e) => {
2092                            tracing::debug!("mock listener error: {e}");
2093                            break;
2094                        }
2095                    }
2096                }
2097            });
2098
2099            Ok(NetworkTcpListener {
2100                port: actual_port,
2101                incoming: rx,
2102            })
2103        }
2104
2105        async fn unlisten_tcp(&self, _port: u16) -> Result<(), NetworkError> {
2106            Ok(())
2107        }
2108
2109        async fn bind_udp(&self, _port: u16) -> Result<NetworkUdpSocket, NetworkError> {
2110            Err(NetworkError::Internal("mock: UDP not supported".into()))
2111        }
2112
2113        async fn ping(&self, _addr: &str) -> Result<PingResult, NetworkError> {
2114            Ok(PingResult {
2115                latency: Duration::from_millis(1),
2116                connection: "direct".to_string(),
2117                peer_addr: None,
2118            })
2119        }
2120
2121        async fn health(&self) -> HealthInfo {
2122            HealthInfo {
2123                state: "running".to_string(),
2124                healthy: true,
2125                ..Default::default()
2126            }
2127        }
2128    }
2129
2130    // ── Helpers ──────────────────────────────────────────────────────
2131
2132    fn make_loopback_peer(id: &str) -> NetworkPeer {
2133        NetworkPeer {
2134            id: id.to_string(),
2135            hostname: format!("truffle-test-{id}"),
2136            ip: "127.0.0.1".parse().unwrap(),
2137            online: true,
2138            cur_addr: Some("127.0.0.1:41641".to_string()),
2139            relay: None,
2140            os: Some("linux".to_string()),
2141            last_seen: Some("2026-03-25T12:00:00Z".to_string()),
2142            key_expiry: None,
2143            dns_name: None,
2144        }
2145    }
2146
2147    fn ws_config(port: u16) -> WsConfig {
2148        WsConfig {
2149            port,
2150            ping_interval: Duration::from_secs(300),
2151            pong_timeout: Duration::from_secs(300),
2152            ..Default::default()
2153        }
2154    }
2155
2156    async fn random_port() -> u16 {
2157        let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2158        l.local_addr().unwrap().port()
2159    }
2160
2161    /// Create a Node backed by a mock provider for testing.
2162    async fn make_test_node(
2163        id: &str,
2164        ws_port: u16,
2165    ) -> (
2166        Node<MockNetworkProvider>,
2167        broadcast::Sender<NetworkPeerEvent>,
2168        Arc<MockNetworkProvider>,
2169    ) {
2170        make_test_node_with_device_id(id, id, ws_port).await
2171    }
2172
2173    /// Like [`make_test_node`] but with a `device_id` distinct from the
2174    /// tailscale id — see [`MockNetworkProvider::new_with_device_id`].
2175    async fn make_test_node_with_device_id(
2176        id: &str,
2177        device_id: &str,
2178        ws_port: u16,
2179    ) -> (
2180        Node<MockNetworkProvider>,
2181        broadcast::Sender<NetworkPeerEvent>,
2182        Arc<MockNetworkProvider>,
2183    ) {
2184        let provider = MockNetworkProvider::new_with_device_id(id, device_id);
2185        let event_tx = provider.event_sender();
2186        let network = Arc::new(provider);
2187        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config(ws_port)));
2188        let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
2189        session.start().await;
2190
2191        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
2192        let node = Node::from_parts(network.clone(), session, codec);
2193
2194        (node, event_tx, network)
2195    }
2196
2197    // ── Tests ────────────────────────────────────────────────────────
2198
2199    // ── Exhaustion tests (review: resource-exhaustion section) ────────
2200
2201    /// Flood the real dispatch path with offers from one peer and assert
2202    /// every bound holds: per-peer pending cap, bounded app queue, and a
2203    /// prompt stop() despite parked decision waits.
2204    #[tokio::test]
2205    async fn offer_flood_respects_caps_and_stop_drains() {
2206        let ws_port = random_port().await;
2207        let (node, _event_tx, _network) = make_test_node("node-flood", ws_port).await;
2208        let node = Arc::new(node);
2209        let mut offers = node.file_transfer().offer_channel(node.clone()).await;
2210
2211        for i in 0..500u32 {
2212            let payload = json!({
2213                "type": "offer",
2214                "file_name": format!("f{i}.bin"),
2215                "size": 1,
2216                "sha256": "0".repeat(64),
2217                "save_path": "",
2218                "token": format!("tok-{i}"),
2219                "tcp_port": 0,
2220            });
2221            let envelope = Envelope::new("ft", "offer", payload).with_timestamp();
2222            node.session
2223                .test_inject_incoming("peer-flood", JsonCodec.encode(&envelope).unwrap());
2224            if i % 64 == 0 {
2225                tokio::task::yield_now().await;
2226            }
2227        }
2228        tokio::time::sleep(Duration::from_millis(300)).await;
2229
2230        let pending = node
2231            .file_transfer_state
2232            .pending_offers_per_peer
2233            .lock()
2234            .unwrap()
2235            .get("peer-flood")
2236            .copied()
2237            .unwrap_or(0);
2238        assert!(
2239            pending <= crate::file_transfer::MAX_PENDING_OFFERS_PER_PEER,
2240            "per-peer pending cap violated: {pending}"
2241        );
2242
2243        // Without an app draining it, the offer queue stays bounded.
2244        let mut buffered = 0;
2245        while offers.try_recv().is_ok() {
2246            buffered += 1;
2247        }
2248        assert!(buffered <= 36, "app offer queue grew to {buffered}");
2249
2250        // Parked offers wait up to 60s for a decision — stop() must cancel
2251        // them instead of waiting that out.
2252        let started = std::time::Instant::now();
2253        node.stop().await;
2254        assert!(
2255            started.elapsed() < Duration::from_secs(5),
2256            "stop() took {:?} with parked offers",
2257            started.elapsed()
2258        );
2259        assert!(node.tasks.tracker.is_empty());
2260    }
2261
2262    /// Churning through dynamic namespaces must not grow the filter map.
2263    #[tokio::test]
2264    async fn dynamic_namespace_churn_stays_bounded() {
2265        let ws_port = random_port().await;
2266        let (node, _event_tx, _network) = make_test_node("node-ns-churn", ws_port).await;
2267        for i in 0..1000 {
2268            let rx = node.subscribe(&format!("dyn-{i}"));
2269            drop(rx);
2270        }
2271        let len = node
2272            .namespace_filters
2273            .read()
2274            .unwrap_or_else(std::sync::PoisonError::into_inner)
2275            .len();
2276        assert!(len <= 1, "namespace map grew to {len} entries");
2277    }
2278
2279    /// Repeated build → use → stop cycles leave no background tasks behind.
2280    #[tokio::test]
2281    async fn lifecycle_churn_leaves_no_tasks() {
2282        for _ in 0..25 {
2283            let ws_port = random_port().await;
2284            let (node, _event_tx, _network) = make_test_node("node-churn", ws_port).await;
2285            let node = Arc::new(node);
2286            let _rx = node.subscribe("churn");
2287            let _offers = node.file_transfer().offer_channel(node.clone()).await;
2288            node.stop().await;
2289            assert!(node.tasks.tracker.is_empty(), "task leak after stop()");
2290        }
2291    }
2292
2293    /// A subscriber that never polls observes Lagged (bounded channel)
2294    /// rather than causing unbounded buffering.
2295    #[tokio::test]
2296    async fn slow_subscriber_lags_instead_of_growing() {
2297        let ws_port = random_port().await;
2298        let (node, _event_tx, _network) = make_test_node("node-lag", ws_port).await;
2299        let mut rx = node.subscribe("chat");
2300
2301        for i in 0..2000 {
2302            let envelope = Envelope::new("chat", "message", json!({ "i": i })).with_timestamp();
2303            node.session
2304                .test_inject_incoming("peer-x", JsonCodec.encode(&envelope).unwrap());
2305            if i % 100 == 0 {
2306                tokio::task::yield_now().await;
2307            }
2308        }
2309        tokio::time::sleep(Duration::from_millis(300)).await;
2310
2311        let mut lagged = false;
2312        loop {
2313            match rx.try_recv() {
2314                Ok(_) => {}
2315                Err(broadcast::error::TryRecvError::Lagged(_)) => lagged = true,
2316                Err(_) => break,
2317            }
2318        }
2319        assert!(lagged, "slow subscriber should observe Lagged");
2320    }
2321
2322    #[tokio::test]
2323    async fn stop_drains_all_background_tasks() {
2324        let ws_port = random_port().await;
2325        let (node, _event_tx, _network) = make_test_node("node-drain", ws_port).await;
2326        let node = Arc::new(node);
2327
2328        // Router is running; also start the FT dispatch task.
2329        let _offers = node.file_transfer().offer_channel(node.clone()).await;
2330        assert!(!node.tasks.tracker.is_empty(), "tasks should be running");
2331
2332        node.stop().await;
2333
2334        // stop() returns only after every node-scoped task has finished.
2335        assert!(
2336            node.tasks.tracker.is_empty(),
2337            "background tasks still alive after stop()"
2338        );
2339        assert!(
2340            node.file_transfer_state
2341                .receiver_handle
2342                .lock()
2343                .await
2344                .is_none(),
2345            "FT receiver handle not cleared by stop()"
2346        );
2347    }
2348
2349    #[tokio::test]
2350    async fn stopped_node_fails_raw_and_resolution_apis_fast() {
2351        let ws_port = random_port().await;
2352        let (node, _event_tx, _network) = make_test_node("node-guards", ws_port).await;
2353        node.stop().await;
2354
2355        assert!(matches!(
2356            node.open_tcp("nobody", 4242).await.unwrap_err(),
2357            NodeError::Stopped
2358        ));
2359        assert!(matches!(
2360            node.listen_tcp(4242).await.unwrap_err(),
2361            NodeError::Stopped
2362        ));
2363        assert!(matches!(
2364            node.connect_quic("nobody", 4242).await.unwrap_err(),
2365            NodeError::Stopped
2366        ));
2367        assert!(matches!(
2368            node.listen_quic(4242).await.unwrap_err(),
2369            NodeError::Stopped
2370        ));
2371        assert!(matches!(
2372            node.bind_udp(4242).await.err(),
2373            Some(NodeError::Stopped)
2374        ));
2375        assert!(matches!(
2376            node.ping("nobody").await.unwrap_err(),
2377            NodeError::Stopped
2378        ));
2379        assert!(matches!(
2380            node.resolve_peer_id("nobody").await.unwrap_err(),
2381            NodeError::Stopped
2382        ));
2383    }
2384
2385    #[test]
2386    fn bytes_payload_roundtrip() {
2387        // Deliberately invalid UTF-8: the representation must not depend
2388        // on the data's contents.
2389        let data = vec![0u8, 159, 146, 150, 255];
2390        let payload = Node::<MockNetworkProvider>::bytes_payload(&data);
2391        assert_eq!(payload["encoding"], "base64");
2392
2393        let msg = NamespacedMessage {
2394            from: "p".into(),
2395            namespace: "ns".into(),
2396            msg_type: "bytes".into(),
2397            payload,
2398            timestamp: None,
2399        };
2400        assert_eq!(msg.payload_bytes().unwrap(), data);
2401    }
2402
2403    #[test]
2404    fn payload_bytes_rejects_other_msg_types() {
2405        let msg = NamespacedMessage {
2406            from: "p".into(),
2407            namespace: "ns".into(),
2408            msg_type: "message".into(),
2409            payload: json!({"data": "aGk="}),
2410            timestamp: None,
2411        };
2412        assert!(msg.payload_bytes().is_none());
2413    }
2414
2415    #[tokio::test]
2416    async fn broadcast_json_reports_zero_peers() {
2417        let ws_port = random_port().await;
2418        let (node, _event_tx, _network) = make_test_node("node-bj", ws_port).await;
2419
2420        let report = node.broadcast_json("ns", &json!({"a": 1})).await.unwrap();
2421        assert_eq!(report.attempted, 0);
2422        assert_eq!(report.queued, 0);
2423        assert!(report.failed.is_empty());
2424    }
2425
2426    #[tokio::test]
2427    async fn subscribe_slow_path_sweeps_dead_namespaces() {
2428        let ws_port = random_port().await;
2429        let (node, _event_tx, _network) = make_test_node("node-sweep", ws_port).await;
2430
2431        let rx = node.subscribe("dyn-a");
2432        drop(rx);
2433        // Creating a NEW namespace takes the slow path, which sweeps
2434        // entries with no live receivers.
2435        let _rx_b = node.subscribe("dyn-b");
2436
2437        let filters = node.namespace_filters.read().unwrap();
2438        assert!(!filters.contains_key("dyn-a"), "dead entry not swept");
2439        assert!(filters.contains_key("dyn-b"));
2440    }
2441
2442    #[tokio::test]
2443    async fn router_prunes_namespace_after_receivers_drop() {
2444        let ws_port = random_port().await;
2445        let (node, _event_tx, _network) = make_test_node("node-prune", ws_port).await;
2446
2447        let rx = node.subscribe("dyn-c");
2448        drop(rx);
2449
2450        // Drive the router with a message for the now-dead namespace.
2451        let envelope = Envelope::new("dyn-c", "message", json!({"x": 1})).with_timestamp();
2452        let data = JsonCodec.encode(&envelope).unwrap();
2453        node.session.test_inject_incoming("peer-x", data);
2454
2455        // The router runs on a background task; poll for the prune.
2456        for _ in 0..50 {
2457            {
2458                let filters = node.namespace_filters.read().unwrap();
2459                if !filters.contains_key("dyn-c") {
2460                    return;
2461                }
2462            }
2463            tokio::time::sleep(Duration::from_millis(10)).await;
2464        }
2465        panic!("dead namespace entry was not pruned by the router");
2466    }
2467
2468    #[tokio::test]
2469    async fn test_node_builder_creates_node() {
2470        let ws_port = random_port().await;
2471        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2472
2473        let identity = node.local_info();
2474        assert_eq!(identity.tailscale_id, "node-1");
2475        assert_eq!(identity.device_id, "node-1");
2476        assert!(identity.tailscale_hostname.contains("node-1"));
2477    }
2478
2479    #[tokio::test]
2480    async fn test_node_peers_from_network() {
2481        let ws_port = random_port().await;
2482        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
2483
2484        // Initially no peers.
2485        let peers = node.peers().await;
2486        assert!(peers.is_empty());
2487
2488        // Inject a peer via Layer 3.
2489        let peer = make_loopback_peer("peer-a");
2490        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2491        tokio::time::sleep(Duration::from_millis(50)).await;
2492
2493        let peers = node.peers().await;
2494        assert_eq!(peers.len(), 1);
2495        assert_eq!(peers[0].tailscale_id, "peer-a");
2496        assert!(
2497            peers[0].device_id.is_none(),
2498            "RFC 022: no ULID before identity"
2499        );
2500        assert!(peers[0].online);
2501        assert!(!peers[0].ws_connected);
2502    }
2503
2504    #[tokio::test]
2505    #[allow(deprecated)] // exercises the legacy send/broadcast contract
2506    async fn test_node_send_to_unknown_peer_errors() {
2507        let ws_port = random_port().await;
2508        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2509
2510        let result = node.send("nonexistent", "test", b"hello").await;
2511        assert!(result.is_err());
2512        let err_str = result.unwrap_err().to_string();
2513        assert!(
2514            err_str.contains("unknown peer") || err_str.contains("not found"),
2515            "expected unknown peer error, got: {err_str}"
2516        );
2517    }
2518
2519    #[tokio::test]
2520    async fn test_node_send_wraps_in_envelope() {
2521        // Test that send() properly creates an envelope.
2522        // We test the codec directly since a full send requires two connected nodes.
2523        let codec = JsonCodec;
2524        let data = b"hello world";
2525        let envelope = Envelope::new("test-ns", "message", serde_json::Value::from(data.to_vec()))
2526            .with_timestamp();
2527
2528        let encoded = codec.encode(&envelope).unwrap();
2529        let decoded = codec.decode(&encoded).unwrap();
2530
2531        assert_eq!(decoded.namespace, "test-ns");
2532        assert_eq!(decoded.msg_type, "message");
2533        assert!(decoded.timestamp.is_some());
2534    }
2535
2536    #[tokio::test]
2537    async fn test_node_subscribe_filters_by_namespace() {
2538        let ws_port = random_port().await;
2539        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2540
2541        // Create subscribers for two different namespaces.
2542        let _rx_chat = node.subscribe("chat");
2543        let _rx_ft = node.subscribe("ft");
2544
2545        // Subscribing to the same namespace again should work.
2546        let _rx_chat2 = node.subscribe("chat");
2547    }
2548
2549    #[tokio::test]
2550    #[allow(deprecated)] // exercises the legacy send/broadcast contract
2551    async fn test_node_broadcast() {
2552        let ws_port = random_port().await;
2553        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2554
2555        // Broadcast with no connected peers should not panic.
2556        node.broadcast("test", b"hello everyone").await;
2557    }
2558
2559    #[tokio::test]
2560    async fn test_node_open_tcp_resolves_peer() {
2561        let ws_port = random_port().await;
2562        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
2563
2564        // Start a TCP server for the test.
2565        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2566        let tcp_port = listener.local_addr().unwrap().port();
2567
2568        // Inject a loopback peer.
2569        let peer = make_loopback_peer("peer-tcp");
2570        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2571        tokio::time::sleep(Duration::from_millis(50)).await;
2572
2573        // Accept a connection in the background.
2574        let accept_handle = tokio::spawn(async move {
2575            let (stream, _) = listener.accept().await.unwrap();
2576            stream
2577        });
2578
2579        // open_tcp should resolve peer-tcp to 127.0.0.1 and connect.
2580        let stream = node.open_tcp("peer-tcp", tcp_port).await;
2581        assert!(stream.is_ok(), "open_tcp failed: {:?}", stream.err());
2582
2583        let _ = accept_handle.await;
2584    }
2585
2586    #[tokio::test]
2587    async fn test_node_open_tcp_unknown_peer_errors() {
2588        let ws_port = random_port().await;
2589        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2590
2591        let result = node.open_tcp("nonexistent", 8080).await;
2592        assert!(result.is_err());
2593        let err_str = result.unwrap_err().to_string();
2594        assert!(
2595            err_str.contains("not found"),
2596            "expected peer not found error, got: {err_str}"
2597        );
2598    }
2599
2600    #[tokio::test]
2601    async fn test_node_ping_resolves_peer() {
2602        let ws_port = random_port().await;
2603        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
2604
2605        // No peer yet.
2606        let result = node.ping("peer-ping").await;
2607        assert!(result.is_err());
2608
2609        // Inject peer.
2610        let peer = make_loopback_peer("peer-ping");
2611        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2612        tokio::time::sleep(Duration::from_millis(50)).await;
2613
2614        // Should succeed (mock returns 1ms latency).
2615        let result = node.ping("peer-ping").await;
2616        assert!(result.is_ok());
2617        assert_eq!(result.unwrap().latency, Duration::from_millis(1));
2618    }
2619
2620    #[tokio::test]
2621    async fn test_node_health() {
2622        let ws_port = random_port().await;
2623        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2624
2625        let health = node.health().await;
2626        assert!(health.healthy);
2627        assert_eq!(health.state, "running");
2628    }
2629
2630    #[tokio::test]
2631    async fn test_node_connect_quic_unknown_peer_errors() {
2632        let ws_port = random_port().await;
2633        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2634
2635        let result = node.connect_quic("peer", 4433).await;
2636        assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
2637    }
2638
2639    #[tokio::test]
2640    async fn test_node_listen_tcp() {
2641        let ws_port = random_port().await;
2642        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
2643
2644        // listen_tcp(0) should bind to an ephemeral port.
2645        let listener = node.listen_tcp(0).await;
2646        assert!(listener.is_ok(), "listen_tcp failed: {:?}", listener.err());
2647    }
2648
2649    #[tokio::test]
2650    async fn test_envelope_serialize_deserialize() {
2651        let envelope = Envelope::new("chat", "message", json!({"text": "hello"})).with_timestamp();
2652
2653        let bytes = envelope.serialize().unwrap();
2654        let decoded = Envelope::deserialize(&bytes).unwrap();
2655
2656        assert_eq!(decoded.namespace, "chat");
2657        assert_eq!(decoded.msg_type, "message");
2658        assert_eq!(decoded.payload["text"], "hello");
2659        assert!(decoded.timestamp.is_some());
2660    }
2661
2662    #[tokio::test]
2663    async fn test_envelope_codec_json() {
2664        let codec = JsonCodec;
2665        let envelope = Envelope::new("ft", "offer", json!({"file": "test.bin"}));
2666
2667        let encoded = codec.encode(&envelope).unwrap();
2668        let decoded = codec.decode(&encoded).unwrap();
2669
2670        assert_eq!(decoded.namespace, "ft");
2671        assert_eq!(decoded.payload["file"], "test.bin");
2672    }
2673
2674    #[tokio::test]
2675    async fn test_envelope_unknown_fields_ignored() {
2676        let json_bytes = br#"{
2677            "namespace": "v2",
2678            "msg_type": "new",
2679            "payload": {},
2680            "future_field": "ignored"
2681        }"#;
2682
2683        let codec = JsonCodec;
2684        let decoded = codec.decode(json_bytes).unwrap();
2685        assert_eq!(decoded.namespace, "v2");
2686        assert_eq!(decoded.msg_type, "new");
2687    }
2688
2689    #[tokio::test]
2690    #[allow(deprecated)] // exercises the legacy send/broadcast contract
2691    async fn test_node_send_and_receive_roundtrip() {
2692        // Set up two nodes that communicate via loopback WS.
2693        let port_a = random_port().await;
2694        let port_b = random_port().await;
2695
2696        let (node_a, event_tx_a, _net_a) = make_test_node("node-a", port_a).await;
2697        let (node_b, event_tx_b, _net_b) = make_test_node("node-b", port_b).await;
2698
2699        // Inject each node as a peer of the other.
2700        let peer_b = NetworkPeer {
2701            id: "node-b".to_string(),
2702            hostname: "truffle-test-node-b".to_string(),
2703            ip: "127.0.0.1".parse().unwrap(),
2704            online: true,
2705            cur_addr: Some("127.0.0.1:41641".to_string()),
2706            relay: None,
2707            os: None,
2708            last_seen: None,
2709            key_expiry: None,
2710            dns_name: None,
2711        };
2712        let peer_a = NetworkPeer {
2713            id: "node-a".to_string(),
2714            hostname: "truffle-test-node-a".to_string(),
2715            ip: "127.0.0.1".parse().unwrap(),
2716            online: true,
2717            cur_addr: Some("127.0.0.1:41641".to_string()),
2718            relay: None,
2719            os: None,
2720            last_seen: None,
2721            key_expiry: None,
2722            dns_name: None,
2723        };
2724
2725        let _ = event_tx_a.send(NetworkPeerEvent::Joined(peer_b));
2726        let _ = event_tx_b.send(NetworkPeerEvent::Joined(peer_a));
2727        tokio::time::sleep(Duration::from_millis(100)).await;
2728
2729        // Subscribe to namespace on node_b.
2730        let mut rx = node_b.subscribe("test");
2731
2732        // Send from node_a to node_b. This triggers lazy WS connect.
2733        // Note: this will connect to node_b's WS listener on port_b.
2734        let send_result = node_a.send("node-b", "test", b"hello from a").await;
2735
2736        // The send may fail in loopback mock because the WS port for node-b
2737        // is the listener port, and the mock's dial connects to 127.0.0.1:port_b.
2738        // In a real scenario with Tailscale, this works because each node
2739        // listens on its own Tailscale IP.
2740        //
2741        // For unit tests, we verify the envelope codec roundtrip works.
2742        // Full integration tests require two separate processes.
2743        if send_result.is_ok() {
2744            // If send succeeded, verify the message arrives.
2745            let msg = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await;
2746            if let Ok(Ok(msg)) = msg {
2747                assert_eq!(msg.namespace, "test");
2748            }
2749        }
2750        // If send fails due to loopback WS peer-id mismatch, that's expected
2751        // in unit tests. The important thing is no panics.
2752    }
2753
2754    // ── Lifecycle: stop() teardown ───────────────────────────────────
2755
2756    #[tokio::test]
2757    #[allow(deprecated)] // exercises the legacy send/broadcast contract
2758    async fn test_node_stop_shuts_down_provider_and_is_idempotent() {
2759        let ws_port = random_port().await;
2760        let (node, event_tx, network) = make_test_node("node-1", ws_port).await;
2761
2762        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-1")));
2763        tokio::time::sleep(Duration::from_millis(50)).await;
2764
2765        assert_eq!(network.stop_call_count(), 0);
2766        node.stop().await;
2767        assert_eq!(
2768            network.stop_call_count(),
2769            1,
2770            "stop() must shut down the provider"
2771        );
2772
2773        // Idempotent: second stop must not stop the provider again.
2774        node.stop().await;
2775        assert_eq!(network.stop_call_count(), 1);
2776
2777        // Post-stop sends fail with NodeError::Stopped.
2778        let err = node.send("peer-1", "test", b"hi").await.unwrap_err();
2779        assert!(matches!(err, NodeError::Stopped));
2780    }
2781
2782    #[tokio::test]
2783    #[allow(deprecated)] // exercises the legacy send/broadcast contract
2784    async fn test_node_stop_closes_ws_connections() {
2785        // A single node that discovers itself as a loopback peer. Under the
2786        // loopback mock every dial lands on the node's own listener, and the
2787        // RFC 022 dial-side identity check drops any connection whose
2788        // answerer is not the dialed peer — so the only WS a mock node can
2789        // legitimately establish is to an entry carrying its own
2790        // tailscale_id. That is all this test needs: a live WS connection
2791        // for stop() to tear down. The distinct device_id keeps the
2792        // self-hello from violating invariant I1 on projection.
2793        let port_a = random_port().await;
2794        let (node_a, event_tx_a, _net_a) =
2795            make_test_node_with_device_id("node-a", "dev-node-a", port_a).await;
2796
2797        let self_peer = NetworkPeer {
2798            id: "node-a".to_string(),
2799            hostname: "truffle-test-node-a".to_string(),
2800            ip: "127.0.0.1".parse().unwrap(),
2801            online: true,
2802            cur_addr: Some("127.0.0.1:41641".to_string()),
2803            relay: None,
2804            os: None,
2805            last_seen: None,
2806            key_expiry: None,
2807            dns_name: None,
2808        };
2809        let _ = event_tx_a.send(NetworkPeerEvent::Joined(self_peer));
2810        tokio::time::sleep(Duration::from_millis(100)).await;
2811
2812        node_a
2813            .send("node-a", "test", b"hello from a")
2814            .await
2815            .expect("loopback self-dial should establish a WS connection");
2816
2817        let peers = node_a.peers().await;
2818        let entry = peers
2819            .iter()
2820            .find(|p| p.tailscale_id == "node-a")
2821            .expect("self peer discovered");
2822        assert!(
2823            entry.ws_connected,
2824            "send() should have established a WS connection"
2825        );
2826
2827        node_a.stop().await;
2828        let peers = node_a.peers().await;
2829        let entry = peers
2830            .iter()
2831            .find(|p| p.tailscale_id == "node-a")
2832            .expect("self peer still discovered");
2833        assert!(
2834            !entry.ws_connected,
2835            "stop() must close and un-mark WS connections"
2836        );
2837    }
2838
2839    // ── RFC 017 Phase 2: resolve_peer_id ─────────────────────────────
2840
2841    /// Helper: inject a peer entry into the session registry and then
2842    /// stamp a synthetic RFC 017 identity on it so `resolve_peer_id`
2843    /// has something to look up. This skips the real hello exchange.
2844    async fn inject_peer_with_identity(
2845        node: &Node<MockNetworkProvider>,
2846        event_tx: &broadcast::Sender<NetworkPeerEvent>,
2847        tailscale_id: &str,
2848        device_id: &str,
2849        device_name: &str,
2850    ) {
2851        let peer = make_loopback_peer(tailscale_id);
2852        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2853        tokio::time::sleep(Duration::from_millis(30)).await;
2854
2855        let identity = crate::session::PeerIdentity {
2856            app_id: "test".into(),
2857            device_id: device_id.into(),
2858            device_name: device_name.into(),
2859            os: "linux".into(),
2860            tailscale_id: tailscale_id.into(),
2861        };
2862        assert!(
2863            node.session
2864                .test_stamp_identity(tailscale_id, identity)
2865                .await,
2866            "peer {tailscale_id} should exist in session registry before stamping identity"
2867        );
2868    }
2869
2870    #[tokio::test]
2871    async fn test_resolve_peer_id_by_device_id() {
2872        let ws_port = random_port().await;
2873        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2874
2875        inject_peer_with_identity(
2876            &node,
2877            &event_tx,
2878            "tailscale-abc",
2879            "01HZZZZZZZZZZZZZZZZZZZZZZZ",
2880            "Alice MacBook",
2881        )
2882        .await;
2883
2884        let resolved = node
2885            .resolve_peer_id("01HZZZZZZZZZZZZZZZZZZZZZZZ")
2886            .await
2887            .unwrap();
2888        assert_eq!(resolved, "01HZZZZZZZZZZZZZZZZZZZZZZZ");
2889    }
2890
2891    #[tokio::test]
2892    async fn test_resolve_peer_id_by_device_name() {
2893        let ws_port = random_port().await;
2894        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2895
2896        inject_peer_with_identity(
2897            &node,
2898            &event_tx,
2899            "tailscale-abc",
2900            "01HXYZXYZXYZXYZXYZXYZXYZXY",
2901            "Bob's Mac",
2902        )
2903        .await;
2904
2905        let resolved = node.resolve_peer_id("Bob's Mac").await.unwrap();
2906        assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
2907    }
2908
2909    #[tokio::test]
2910    async fn test_resolve_peer_id_by_device_id_prefix() {
2911        let ws_port = random_port().await;
2912        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2913
2914        inject_peer_with_identity(
2915            &node,
2916            &event_tx,
2917            "tailscale-abc",
2918            "01HXYZXYZXYZXYZXYZXYZXYZXY",
2919            "laptop",
2920        )
2921        .await;
2922
2923        // Prefix match — 4 chars is the minimum the implementation
2924        // accepts.
2925        let resolved = node.resolve_peer_id("01HX").await.unwrap();
2926        assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
2927    }
2928
2929    #[tokio::test]
2930    async fn test_resolve_peer_id_by_tailscale_id_legacy() {
2931        // Escape hatch: resolving by the Tailscale stable ID should
2932        // still work and return the device_id (or the tailscale_id as
2933        // fallback when no identity is populated).
2934        let ws_port = random_port().await;
2935        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2936
2937        inject_peer_with_identity(
2938            &node,
2939            &event_tx,
2940            "tailscale-legacy",
2941            "01HLEGACY0000000000000000X",
2942            "legacy box",
2943        )
2944        .await;
2945
2946        let resolved = node.resolve_peer_id("tailscale-legacy").await.unwrap();
2947        assert_eq!(resolved, "01HLEGACY0000000000000000X");
2948    }
2949
2950    #[tokio::test]
2951    async fn test_resolve_peer_id_unknown() {
2952        let ws_port = random_port().await;
2953        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
2954        let result = node.resolve_peer_id("nope").await;
2955        assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
2956    }
2957
2958    // ── RFC 021: raw transport surface ────────────────────────────────
2959
2960    #[tokio::test]
2961    async fn test_resolve_peer_by_bare_name_before_hello() {
2962        // Raw-transport-only peers have no hello identity; the bare device
2963        // name must still resolve via the hostname slug (RFC 021 smoke-test
2964        // finding: only the full `truffle-{app}-{slug}` hostname matched).
2965        let ws_port = random_port().await;
2966        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2967
2968        let mut peer = make_loopback_peer("nodeid-9");
2969        peer.hostname = "truffle-test-ec2-smoke".to_string();
2970        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2971        tokio::time::sleep(Duration::from_millis(50)).await;
2972
2973        // Bare slug, and an unslugged variant that normalizes to it.
2974        assert_eq!(node.resolve_peer("ec2-smoke").await.unwrap().id, "nodeid-9");
2975        assert_eq!(node.resolve_peer("EC2 Smoke").await.unwrap().id, "nodeid-9");
2976        // Unknown bare names still miss.
2977        assert!(node.resolve_peer("other-box").await.is_err());
2978    }
2979
2980    #[tokio::test]
2981    async fn test_peers_device_name_strips_hostname_before_hello() {
2982        let ws_port = random_port().await;
2983        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2984
2985        let mut peer = make_loopback_peer("nodeid-9");
2986        peer.hostname = "truffle-test-ec2-smoke".to_string();
2987        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2988        tokio::time::sleep(Duration::from_millis(50)).await;
2989
2990        let peers = node.peers().await;
2991        assert_eq!(peers.len(), 1);
2992        // Pre-identity: device_name is None; display_name uses stripped slug.
2993        assert!(peers[0].device_name.is_none());
2994        assert_eq!(peers[0].display_name, "ec2-smoke");
2995        assert_eq!(peers[0].hostname, "truffle-test-ec2-smoke");
2996        assert!(peers[0].device_id.is_none());
2997    }
2998
2999    #[tokio::test]
3000    async fn test_resolve_peer_stale_ref_is_peer_gone() {
3001        let ws_port = random_port().await;
3002        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
3003
3004        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-9")));
3005        tokio::time::sleep(Duration::from_millis(50)).await;
3006
3007        let live_ref = node.peers().await[0].peer_ref.clone();
3008        assert_eq!(node.resolve_peer(&live_ref).await.unwrap().id, "peer-9");
3009
3010        // Left + rejoin bumps the generation: the old handle's ref must fail
3011        // with PeerGone (RFC 022 I5), never silently reach the new
3012        // generation.
3013        let _ = event_tx.send(NetworkPeerEvent::Left("peer-9".to_string()));
3014        tokio::time::sleep(Duration::from_millis(50)).await;
3015        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-9")));
3016        tokio::time::sleep(Duration::from_millis(50)).await;
3017
3018        assert!(matches!(
3019            node.resolve_peer(&live_ref).await,
3020            Err(NodeError::PeerGone(_))
3021        ));
3022        // A ref for a fully departed peer is PeerGone too — not a typo-shaped
3023        // PeerNotFound.
3024        assert!(matches!(
3025            node.resolve_peer("peer-9:99").await,
3026            Err(NodeError::PeerGone(_))
3027        ));
3028        // peer() propagates PeerGone immediately instead of waiting out the
3029        // timeout.
3030        assert!(matches!(
3031            node.peer(&live_ref, Some(2_000)).await,
3032            Err(NodeError::PeerGone(_))
3033        ));
3034    }
3035
3036    #[tokio::test]
3037    async fn test_resolve_peer_accepts_ip() {
3038        let ws_port = random_port().await;
3039        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
3040
3041        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-a")));
3042        tokio::time::sleep(Duration::from_millis(50)).await;
3043
3044        let resolved = node.resolve_peer("127.0.0.1").await.unwrap();
3045        assert_eq!(resolved.id, "peer-a");
3046        // resolve_peer_id falls back to the tailscale id when no hello
3047        // identity is populated yet.
3048        assert_eq!(node.resolve_peer_id("127.0.0.1").await.unwrap(), "peer-a");
3049    }
3050
3051    #[tokio::test]
3052    async fn test_listen_tcp_rejects_reserved_ports() {
3053        let ws_port = random_port().await;
3054        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
3055
3056        let err = node.listen_tcp(9417).await.unwrap_err();
3057        assert!(
3058            matches!(err, NodeError::ReservedPort(9417)),
3059            "expected ReservedPort(9417), got: {err}"
3060        );
3061
3062        // RFC 023 D4: 443 is no longer reserved — the guard must not reject
3063        // it. The mock binds a real host socket and 443 is privileged on
3064        // most systems, so accept either outcome; what matters is that
3065        // ReservedPort is gone and 443 bind failures carry the
3066        // sidecar-upgrade hint.
3067        match node.listen_tcp(443).await {
3068            Ok(listener) => assert_eq!(listener.port, 443),
3069            Err(NodeError::ReservedPort(_)) => panic!("443 must not be reserved (RFC 023 D4)"),
3070            Err(e) => assert!(
3071                e.to_string().contains("RFC 023"),
3072                "443 bind errors should hint at the sidecar upgrade, got: {e}"
3073            ),
3074        }
3075    }
3076
3077    #[tokio::test]
3078    async fn test_listen_quic_rejects_reserved_and_ephemeral_ports() {
3079        let ws_port = random_port().await;
3080        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
3081
3082        let err = node.listen_quic(9417).await.unwrap_err();
3083        assert!(matches!(err, NodeError::ReservedPort(9417)));
3084
3085        let err = node.listen_quic(0).await.unwrap_err();
3086        assert!(matches!(err, NodeError::NotImplemented(_)));
3087    }
3088
3089    #[test]
3090    fn test_builder_hostname_override_validation() {
3091        assert!(NodeBuilder::default().hostname("dashboard").is_ok());
3092        assert!(NodeBuilder::default().hostname("my-app-1").is_ok());
3093        for bad in ["", "Dashboard", "has.dot", "-lead", "trail-"] {
3094            assert!(
3095                NodeBuilder::default().hostname(bad).is_err(),
3096                "expected hostname {bad:?} to be rejected"
3097            );
3098        }
3099        let too_long = "x".repeat(64);
3100        assert!(NodeBuilder::default().hostname(too_long).is_err());
3101    }
3102
3103    #[tokio::test]
3104    async fn test_bind_udp_falls_back_to_direct_socket() {
3105        // The mock provider has no UDP support, so bind_udp exercises the
3106        // direct-socket fallback and must return a usable socket.
3107        let ws_port = random_port().await;
3108        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
3109
3110        let socket = node.bind_udp(0).await.unwrap();
3111        let addr = socket.local_addr().unwrap();
3112        assert_ne!(addr.port(), 0);
3113    }
3114
3115    #[test]
3116    fn test_raw_alpn_scoping() {
3117        assert_eq!(
3118            crate::transport::quic::raw_alpn("demo"),
3119            b"truffle-raw.demo".to_vec()
3120        );
3121        assert_eq!(
3122            crate::transport::quic::raw_alpn(""),
3123            b"truffle-raw".to_vec()
3124        );
3125    }
3126
3127    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3128    async fn test_connect_quic_roundtrip_via_raw_listener() {
3129        let ws_port = random_port().await;
3130        let (client_node, event_tx, _net) = make_test_node("cli", ws_port).await;
3131
3132        // Raw listener on port 0 (direct-socket fallback path) with the
3133        // ALPN that connect_quic derives from the mock app_id ("test").
3134        let server_network = Arc::new(MockNetworkProvider::new("srv"));
3135        let alpn = crate::transport::quic::raw_alpn("test");
3136        let listener = crate::transport::quic::listen_raw(&server_network, 0, &alpn)
3137            .await
3138            .unwrap();
3139        let port = listener.port();
3140        assert_ne!(port, 0);
3141
3142        let echo_task = tokio::spawn(async move {
3143            let conn = listener.accept().await.expect("no incoming connection");
3144            let mut stream = conn
3145                .accept_stream()
3146                .await
3147                .unwrap()
3148                .expect("connection closed before stream");
3149            let mut received = Vec::new();
3150            while let Some(chunk) = stream.read(1024).await.unwrap() {
3151                received.extend_from_slice(&chunk);
3152            }
3153            stream.write(&received).await.unwrap();
3154            stream.finish();
3155            // Keep the connection alive until the peer has read the echo —
3156            // closing immediately could drop in-flight stream data.
3157            tokio::time::sleep(Duration::from_millis(500)).await;
3158            conn.close();
3159        });
3160
3161        // Register the "server" as a peer at 127.0.0.1 and connect by name.
3162        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("srv-peer")));
3163        tokio::time::sleep(Duration::from_millis(50)).await;
3164
3165        let conn = client_node.connect_quic("srv-peer", port).await.unwrap();
3166        let mut stream = conn.open_stream().await.unwrap();
3167        stream.write(b"hello quic").await.unwrap();
3168        stream.finish();
3169
3170        let mut echoed = Vec::new();
3171        while let Some(chunk) = stream.read(1024).await.unwrap() {
3172            echoed.extend_from_slice(&chunk);
3173        }
3174        assert_eq!(echoed, b"hello quic");
3175
3176        echo_task.await.unwrap();
3177        conn.close();
3178    }
3179
3180    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3181    async fn test_quic_raw_alpn_mismatch_rejected() {
3182        // Cross-app connections must fail the TLS handshake outright —
3183        // ALPN is the QUIC analog of the WS hello's app_id check.
3184        let server_network = Arc::new(MockNetworkProvider::new("srv"));
3185        let listener = crate::transport::quic::listen_raw(
3186            &server_network,
3187            0,
3188            &crate::transport::quic::raw_alpn("app-a"),
3189        )
3190        .await
3191        .unwrap();
3192        let port = listener.port();
3193
3194        let client_network = Arc::new(MockNetworkProvider::new("cli"));
3195        let result = crate::transport::quic::connect_raw(
3196            &client_network,
3197            "127.0.0.1",
3198            port,
3199            &crate::transport::quic::raw_alpn("app-b"),
3200        )
3201        .await;
3202
3203        assert!(
3204            result.is_err(),
3205            "cross-app QUIC connect must fail (ALPN mismatch)"
3206        );
3207    }
3208
3209    // ── RFC 022 honest projection ─────────────────────────────────────
3210
3211    #[tokio::test]
3212    async fn test_rfc022_node_peer_resolves_and_wait_miss_returns_none() {
3213        let ws_port = random_port().await;
3214        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
3215
3216        // Miss without wait
3217        assert!(node.peer("nope", None).await.unwrap().is_none());
3218
3219        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-a")));
3220        tokio::time::sleep(Duration::from_millis(50)).await;
3221
3222        let p = node.peer("peer-a", None).await.unwrap().expect("found");
3223        assert_eq!(p.tailscale_id, "peer-a");
3224        assert!(p.device_id.is_none());
3225
3226        // waitMs on unknown times out to None
3227        let start = std::time::Instant::now();
3228        let miss = node.peer("still-missing", Some(80)).await.unwrap();
3229        assert!(miss.is_none());
3230        assert!(start.elapsed() >= Duration::from_millis(70));
3231    }
3232
3233    #[test]
3234    fn test_rfc022_peer_projection_never_uses_tailscale_as_device_id() {
3235        use crate::session::PeerState;
3236        use std::net::Ipv4Addr;
3237
3238        // Pre-identity
3239        let pre = PeerState {
3240            id: "ts-abc".into(),
3241            generation: 1,
3242            name: "truffle-app-laptop".into(),
3243            ip: Ipv4Addr::new(100, 64, 0, 1).into(),
3244            online: true,
3245            ws_connected: false,
3246            connection_type: "direct".into(),
3247            os: None,
3248            last_seen: None,
3249            identity: None,
3250            identity_suppressed: false,
3251        };
3252        let p = Peer::from(pre);
3253        assert!(p.device_id.is_none());
3254        assert_eq!(p.tailscale_id, "ts-abc");
3255        assert_eq!(p.peer_ref, "ts-abc:1");
3256        assert_eq!(p.display_name, "laptop");
3257        assert!(p
3258            .device_id
3259            .as_ref()
3260            .map(|d| d.as_str() != p.tailscale_id)
3261            .unwrap_or(true));
3262
3263        // Post-identity
3264        let post = PeerState {
3265            id: "ts-abc".into(),
3266            generation: 1,
3267            name: "truffle-app-laptop".into(),
3268            ip: Ipv4Addr::new(100, 64, 0, 1).into(),
3269            online: true,
3270            ws_connected: true,
3271            connection_type: "direct".into(),
3272            os: Some("darwin".into()),
3273            last_seen: None,
3274            identity: Some(crate::session::PeerIdentity {
3275                app_id: "app".into(),
3276                device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
3277                device_name: "Alice's Mac".into(),
3278                os: "darwin".into(),
3279                tailscale_id: "ts-abc".into(),
3280            }),
3281            identity_suppressed: false,
3282        };
3283        let p = Peer::from(post);
3284        assert_eq!(p.device_id.as_deref(), Some("01J4K9M2Z8AB3RNYQPW6H5TC0X"));
3285        assert_ne!(p.device_id.as_deref().unwrap(), p.tailscale_id);
3286        assert_eq!(p.display_name, "Alice's Mac");
3287
3288        // Suppressed (first-wins loser)
3289        let suppressed = PeerState {
3290            id: "ts-xyz".into(),
3291            generation: 1,
3292            name: "truffle-app-other".into(),
3293            ip: Ipv4Addr::new(100, 64, 0, 2).into(),
3294            online: true,
3295            ws_connected: true,
3296            connection_type: "direct".into(),
3297            os: None,
3298            last_seen: None,
3299            identity: Some(crate::session::PeerIdentity {
3300                app_id: "app".into(),
3301                device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
3302                device_name: "Clone".into(),
3303                os: "linux".into(),
3304                tailscale_id: "ts-xyz".into(),
3305            }),
3306            identity_suppressed: true,
3307        };
3308        let p = Peer::from(suppressed);
3309        assert!(
3310            p.device_id.is_none(),
3311            "suppressed claim must project null device_id"
3312        );
3313    }
3314}