Skip to main content

shep_core/
transport.rs

1//! The control transport: one address, two implementations
2//!
3//! shep's control plane is a length-delimited framed byte stream between a
4//! client and the shepherd. [`protocol`](crate::protocol) owns what travels
5//! over it; this module owns what carries it, and it is the only place in
6//! the workspace that names an OS transport type.
7//!
8//! On unix that carrier is an `AF_UNIX` socket. On Windows it is a named
9//! pipe. Both are byte streams with the same framing, so everything above
10//! this module — the codec, the handshake, the actor, every RPC verb — is
11//! identical on both platforms and carries no `cfg` at all. That is the
12//! whole point of the seam: the platform difference is spent once, here.
13//!
14//! # The address
15//!
16//! Both halves take a [`Path`], which [`ShepPaths::socket`](crate::paths::ShepPaths::socket)
17//! resolves per-platform: a real socket file under `$SHEP_HOME/run` on unix,
18//! and the `\\.\pipe\shep-<home>` name on Windows. A pipe name is
19//! path-*shaped* but is not a filesystem path — nothing may take its
20//! `parent()` or ask whether it `exists()`.
21//!
22//! # What differs, and why it is not hidden
23//!
24//! A `UnixListener` accepts repeatedly from one descriptor. A named pipe
25//! server instance is *consumed* by the client that connects to it, so the
26//! server must create a fresh instance for the next caller. [`Listener`]
27//! absorbs that difference behind one `accept()`, which is why it takes
28//! `&mut self` where a bare `UnixListener::accept` needs only `&self`.
29//!
30//! # Security
31//!
32//! The unix tier's primary access control is the `0700` on `$SHEP_HOME/run`
33//! — a peer that cannot traverse the directory cannot reach the socket at
34//! all — with the same-uid `peer_cred` check behind it as a second layer.
35//! `shep-daemon`'s `RpcServer` carries the canonical writeup.
36//!
37//! **Windows answers the same question with the pipe's own ACL, and the
38//! shape of the answer is different enough to state plainly.** A named pipe
39//! created with a default security descriptor grants full control to the
40//! creating user, `LocalSystem` and `Administrators`, and grants *read*
41//! access to `Everyone`. It does not grant write access to `Everyone`, and
42//! that asymmetry is what makes the posture defensible: a client must open
43//! the pipe for **both** read and write to speak this protocol at all,
44//! because the daemon sends nothing before it has received a `Hello`. A
45//! foreign local user's open for write is refused by the OS at `CreateFile`
46//! time — fail-closed, before a single byte reaches shep's own code — and
47//! an open for read alone yields a connection the daemon never writes to.
48//!
49//! Two consequences are worth naming rather than leaving to be discovered:
50//!
51//! - There is **no post-accept peer check on Windows**, and none is needed
52//!   for the same-user question, because the OS already answered it at open
53//!   time. The unix tier's two layers collapse into one that is enforced
54//!   earlier. Establishing the *identity* of an already-admitted peer would
55//!   need `ImpersonateNamedPipeClient` and a token-SID comparison, which is
56//!   raw FFI this crate's `#![forbid(unsafe_code)]` does not permit; it is
57//!   not built because it would be a second answer to a settled question,
58//!   not because it was overlooked.
59//! - Administrators can reach the pipe. So can they reach a `0700`
60//!   directory, and shep's unix writeup already lists root as an explicit
61//!   non-goal, so this is parity rather than a regression.
62//!
63//! [`Listener::bind`] additionally sets `reject_remote_clients`, so the pipe
64//! is unreachable over SMB from another machine. Tokio defaults it on; it is
65//! set explicitly here because it is load-bearing and a default that matters
66//! should be visible at the call site.
67
68use std::io;
69use std::path::Path;
70
71/// How long a contended connect waits before retrying.
72///
73/// Windows only, and it is not a timeout — the caller's own budget bounds
74/// the loop (see [`connect`]). Short enough that a server between instances
75/// is not noticeably slower to reach than one already waiting.
76#[cfg(windows)]
77const PIPE_BUSY_RETRY: std::time::Duration = std::time::Duration::from_millis(20);
78
79/// Windows' `ERROR_PIPE_BUSY`: the pipe exists but every server instance is
80/// already spoken for. Transient by definition — the server creates the next
81/// instance immediately after accepting — so it is retried, never surfaced.
82///
83/// Hardcoded rather than pulled from `windows-sys`: this crate has no
84/// Windows-only dependency, and one stable, well-known error code does not
85/// earn it one. The same reasoning [`kv`](crate::kv) applies to
86/// `ERROR_SHARING_VIOLATION`.
87#[cfg(windows)]
88const ERROR_PIPE_BUSY: i32 = 231;
89
90/// The connected stream a **client** holds.
91///
92/// A concrete type alias rather than a `Box<dyn AsyncRead + AsyncWrite>`:
93/// there is exactly one implementation per platform, chosen at compile time,
94/// so a trait object would cost a vtable and an allocation to express a
95/// choice that was already made.
96#[cfg(unix)]
97pub type ClientStream = tokio::net::UnixStream;
98/// The connected stream a **client** holds.
99#[cfg(windows)]
100pub type ClientStream = tokio::net::windows::named_pipe::NamedPipeClient;
101
102/// The connected stream the **daemon** holds for one accepted peer.
103///
104/// The same type as [`ClientStream`] on unix, where a socketpair's two ends
105/// are indistinguishable; a distinct type on Windows, where the server end
106/// of a pipe is its own type. Keeping them separately named means neither
107/// side's code has to know which case it is in.
108#[cfg(unix)]
109pub type ServerStream = tokio::net::UnixStream;
110/// The connected stream the **daemon** holds for one accepted peer.
111#[cfg(windows)]
112pub type ServerStream = tokio::net::windows::named_pipe::NamedPipeServer;
113
114/// The reading half of a [`ServerStream`], after [`split`].
115pub type ServerReadHalf = tokio::io::ReadHalf<ServerStream>;
116/// The writing half of a [`ServerStream`], after [`split`].
117pub type ServerWriteHalf = tokio::io::WriteHalf<ServerStream>;
118
119/// Splits an accepted connection into halves that can be owned by two tasks.
120///
121/// The daemon reads requests on one task and writes replies (and pushed bus
122/// events) on another, so the two halves must be separately owned and
123/// `'static`.
124///
125/// [`tokio::io::split`] rather than `UnixStream::into_split`, which exists
126/// only on unix. The generic split coordinates the two halves through an
127/// internal lock where `into_split` needs none, so this trades a small,
128/// uncontended synchronisation cost on every frame for one code path on both
129/// platforms. That is the right trade here and would not be everywhere: this
130/// is a control plane carrying operator RPC — a handful of frames per
131/// command — not a data path. Do not copy the reasoning to one.
132#[must_use]
133pub fn split(stream: ServerStream) -> (ServerReadHalf, ServerWriteHalf) {
134    tokio::io::split(stream)
135}
136
137/// A connected `(daemon side, client side)` pair over the real platform
138/// transport.
139///
140/// For tests that need a live connection without standing up a daemon. It is
141/// a **real** transport on both platforms, not an in-memory duplex: on unix a
142/// socketpair, on Windows an actual named pipe with a unique name. That
143/// matters, because the thing most worth testing at this layer is behaviour
144/// that an in-memory pipe would not reproduce — a peer closing mid-frame, a
145/// half-open connection, the exact error a dead peer's write produces.
146///
147/// The Windows arm picks its own name rather than taking one, so callers
148/// need no address and no tempdir; process id plus a monotonic counter keeps
149/// concurrent tests off each other in a namespace that is machine-global.
150///
151/// # Errors
152///
153/// Whatever the OS says while creating or connecting the pair.
154pub async fn connected_pair() -> io::Result<(ServerStream, ClientStream)> {
155    #[cfg(unix)]
156    {
157        tokio::net::UnixStream::pair()
158    }
159    #[cfg(windows)]
160    {
161        use core::sync::atomic::{AtomicU64, Ordering};
162        static NEXT: AtomicU64 = AtomicU64::new(0);
163
164        let name = std::path::PathBuf::from(format!(
165            r"\\.\pipe\shep-pair-{}-{}",
166            std::process::id(),
167            NEXT.fetch_add(1, Ordering::Relaxed)
168        ));
169        let mut listener = Listener::bind(&name)?;
170        // Concurrently, not in sequence: `accept` does not resolve until a
171        // client attaches, and `connect` cannot attach until the server is
172        // waiting, so awaiting either one alone would deadlock.
173        tokio::try_join!(listener.accept(), connect(&name))
174    }
175}
176
177/// Dials the shepherd at `addr`.
178///
179/// # Errors
180///
181/// Whatever the OS says. The one case handled rather than returned is
182/// Windows' `ERROR_PIPE_BUSY`, which means the pipe exists but every server
183/// instance is currently serving someone: that is transient and is retried.
184///
185/// **This loop is deliberately unbounded, and is safe only because every
186/// caller bounds it.** `shep-client`'s `Connection::open` wraps the whole
187/// connect-plus-handshake in one `tokio::time::timeout`, so a pipe that
188/// stays busy forever surfaces as that layer's `HandshakeTimeout` — the same
189/// error a unix socket that is bound but never accepted from produces, which
190/// is the behaviour the two platforms should share. A bound here as well
191/// would be a second, quieter deadline competing with it.
192pub async fn connect(addr: &Path) -> io::Result<ClientStream> {
193    #[cfg(unix)]
194    {
195        tokio::net::UnixStream::connect(addr).await
196    }
197    #[cfg(windows)]
198    {
199        use tokio::net::windows::named_pipe::ClientOptions;
200        loop {
201            match ClientOptions::new().open(addr) {
202                Ok(client) => return Ok(client),
203                Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => {
204                    tokio::time::sleep(PIPE_BUSY_RETRY).await;
205                }
206                Err(err) => return Err(err),
207            }
208        }
209    }
210}
211
212/// The listening half of the control transport.
213///
214/// Holds a bound `UnixListener` on unix, and on Windows the *next* idle
215/// named pipe server instance — see [`Self::accept`] for why that is one
216/// instance rather than a listener.
217#[derive(Debug)]
218pub struct Listener {
219    #[cfg(unix)]
220    listener: tokio::net::UnixListener,
221    /// The idle instance the next client will connect to, taken on every
222    /// accept and replaced with a fresh one.
223    ///
224    /// `None` only between an accept handing out its connection and the
225    /// replacement being created, and across an accept whose replacement
226    /// could not be created at all. [`Self::accept`] makes one when it
227    /// finds the slot empty, which is what keeps a single failed `create`
228    /// from ending the daemon's ability to serve anyone.
229    #[cfg(windows)]
230    server: Option<tokio::net::windows::named_pipe::NamedPipeServer>,
231    /// The pipe name, kept so each replacement instance can be created on
232    /// the same name.
233    #[cfg(windows)]
234    addr: std::ffi::OsString,
235}
236
237impl Listener {
238    /// Binds the control transport at `addr`.
239    ///
240    /// # Errors
241    ///
242    /// Whatever the OS says: the socket path is unwritable or already bound
243    /// on unix, the pipe name is already owned on Windows.
244    ///
245    /// # Single instance
246    ///
247    /// The Windows arm passes `first_pipe_instance(true)`, which makes this
248    /// call itself the daemon's mutual exclusion: a second shepherd trying
249    /// the same `$SHEP_HOME` fails here with `ERROR_ACCESS_DENIED` rather
250    /// than quietly creating a second instance of the same pipe and stealing
251    /// half the connections. That is a stronger guarantee than the unix
252    /// arm's, where binding over a stale socket file is possible and the
253    /// pidfile lock is what actually excludes a second daemon — and it is
254    /// free, which is why it is used rather than reproducing the pidfile
255    /// dance on a platform that does not need it.
256    ///
257    /// It also removes the stale-socket problem entirely instead of solving
258    /// it: a pipe has no directory entry, so a daemon that died leaves
259    /// nothing behind to recover from. There is no Windows equivalent of the
260    /// "connect, fail, unlink, rebind" sequence, because there is nothing to
261    /// unlink.
262    pub fn bind(addr: &Path) -> io::Result<Self> {
263        #[cfg(unix)]
264        {
265            Ok(Self {
266                listener: tokio::net::UnixListener::bind(addr)?,
267            })
268        }
269        #[cfg(windows)]
270        {
271            use tokio::net::windows::named_pipe::ServerOptions;
272            let server = ServerOptions::new()
273                .first_pipe_instance(true)
274                .reject_remote_clients(true)
275                .create(addr)?;
276            Ok(Self {
277                server: Some(server),
278                addr: addr.as_os_str().to_os_string(),
279            })
280        }
281    }
282
283    /// Wraps a socket this process was handed rather than one it bound.
284    ///
285    /// The successor's half of a daemon handover. The control socket is one
286    /// of the descriptors an outgoing shepherd passes across its `execve`,
287    /// so the image that takes over adopts the listener instead of binding
288    /// the address again: a rebind would race the predecessor's socket file
289    /// and lose whatever connection a client had already made.
290    ///
291    /// Unix only, and the whole handover is. Windows has no `execve`, and
292    /// its arm of [`Self::bind`] makes the bind itself the daemon's mutual
293    /// exclusion, so a second image could not create the pipe to hand on in
294    /// the first place.
295    #[cfg(unix)]
296    #[must_use]
297    pub fn from_unix_listener(listener: tokio::net::UnixListener) -> Self {
298        Self { listener }
299    }
300
301    /// The descriptor this listener is bound on.
302    ///
303    /// The predecessor's half of a daemon handover, and the counterpart to
304    /// [`Self::from_unix_listener`]: an outgoing shepherd has to name this
305    /// number in the blob it hands on, since a descriptor number is only
306    /// meaningful in the process that owns it and the successor adopts it by
307    /// number. Borrowed, never owned: closing it would close the control
308    /// socket out from under a daemon that is still serving.
309    ///
310    /// Unix only, as the whole handover is.
311    #[cfg(unix)]
312    #[must_use]
313    pub fn as_raw_fd(&self) -> std::os::fd::RawFd {
314        use std::os::fd::AsRawFd as _;
315        self.listener.as_raw_fd()
316    }
317
318    /// Waits for the next peer and returns its connected stream.
319    ///
320    /// # Errors
321    ///
322    /// Whatever the OS says. A transient failure is the caller's to log and
323    /// continue from — one bad accept must not end a daemon.
324    ///
325    /// # Cancellation safety
326    ///
327    /// Safe on both platforms, which the daemon's accept loop depends on: it
328    /// `select!`s this against a shutdown watch, and a cancelled accept must
329    /// not drop a peer that was mid-connect. `UnixListener::accept` and
330    /// `NamedPipeServer::connect` are both documented cancel-safe, and the
331    /// Windows arm's instance swap happens only *after* `connect` has
332    /// resolved, so a cancellation cannot leave this holding an instance
333    /// that a client has already been handed.
334    ///
335    /// # Why `&mut self`
336    ///
337    /// A named pipe server instance is consumed by whoever connects to it:
338    /// once a client is attached, that instance *is* the connection and can
339    /// never accept again. So accepting means handing out the instance we
340    /// were holding and creating the next one, which needs exclusive access.
341    /// The unix arm needs only `&self` and takes `&mut self` anyway, so both
342    /// platforms present one signature.
343    pub async fn accept(&mut self) -> io::Result<ServerStream> {
344        #[cfg(unix)]
345        {
346            let (stream, _addr) = self.listener.accept().await?;
347            Ok(stream)
348        }
349        #[cfg(windows)]
350        {
351            use tokio::net::windows::named_pipe::ServerOptions;
352            // The slot is empty only if a previous accept could not create
353            // a replacement. Make one now rather than at that failure, so a
354            // transient `create` error costs one accept instead of the
355            // daemon's whole ability to serve.
356            if self.server.is_none() {
357                self.server = Some(
358                    ServerOptions::new()
359                        .reject_remote_clients(true)
360                        .create(&self.addr)?,
361                );
362            }
363            // Resolves when a client attaches to the instance we hold.
364            let Some(server) = self.server.as_ref() else {
365                unreachable!("the slot was just filled")
366            };
367            server.connect().await?;
368            // Out of the slot BEFORE anything that can fail. Once a client
369            // has attached, this instance IS that connection and can never
370            // accept again, so leaving it in place would mean the next
371            // accept calling `connect` on a connected instance: Windows
372            // answers that with ERROR_PIPE_CONNECTED or ERROR_NO_DATA
373            // rather than waiting, and the daemon's accept loop, which
374            // logs an error and carries on, would spin on it forever
375            // instead of serving anyone.
376            let connected = self
377                .server
378                .take()
379                .unwrap_or_else(|| unreachable!("the slot was just filled"));
380            // `first_pipe_instance` is deliberately NOT set here: it is set
381            // once, at `bind`, and setting it again would refuse to create
382            // the very instance this listener already owns the name for.
383            //
384            // A failure here leaves the slot empty and the peer connected,
385            // which is the right way round. The alternative, `?` before the
386            // handoff, drops a peer that has already attached AND leaves a
387            // connected instance in the slot for the next accept to trip
388            // over. The error is not lost: the next accept re-creates and
389            // reports it, having handed this peer over first.
390            self.server = ServerOptions::new()
391                .reject_remote_clients(true)
392                .create(&self.addr)
393                .ok();
394            Ok(connected)
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
403
404    /// A control address that is valid on the platform running the test:
405    /// a file inside `dir` on unix, a uniquely-named pipe on Windows (where
406    /// `dir` is irrelevant, since a pipe name is not a filesystem path).
407    ///
408    /// `tag` keeps concurrently running tests off each other's address —
409    /// the pipe namespace is machine-global, so two tests using one name
410    /// would contend for real.
411    fn address(dir: &Path, tag: &str) -> std::path::PathBuf {
412        #[cfg(unix)]
413        {
414            let _ = tag;
415            dir.join("shep.sock")
416        }
417        #[cfg(windows)]
418        {
419            let _ = dir;
420            std::path::PathBuf::from(format!(
421                r"\\.\pipe\shep-transport-test-{tag}-{}",
422                std::process::id()
423            ))
424        }
425    }
426
427    /// fails if a listener rebuilt around an inherited socket cannot serve.
428    ///
429    /// The successor half of a daemon handover: the control socket is one
430    /// descriptor the outgoing shepherd hands on, so its replacement binds
431    /// nothing and wraps what it was given. A `bind` here instead would
432    /// meet the socket file the predecessor left behind, and the connection
433    /// a client had already made to it would be dropped on the floor.
434    #[cfg(unix)]
435    #[tokio::test]
436    async fn a_listener_built_around_an_inherited_socket_still_accepts() {
437        let dir = tempfile::tempdir().unwrap();
438        let addr = address(dir.path(), "adopted");
439        let inherited = tokio::net::UnixListener::bind(&addr).unwrap();
440
441        let mut listener = Listener::from_unix_listener(inherited);
442        let client = tokio::spawn(async move {
443            let mut stream = connect(&addr).await.unwrap();
444            stream.write_all(b"still here\n").await.unwrap();
445        });
446
447        // Every await bounded, as `dialing_an_address_with_no_listener_
448        // fails_rather_than_hanging` above already does. An adopted listener
449        // that stopped accepting would otherwise hang this case, and a hang
450        // stops the whole test binary rather than failing one test, so CI
451        // times out with no assertion to point at.
452        let bound = std::time::Duration::from_secs(10);
453        let mut served = tokio::time::timeout(bound, listener.accept())
454            .await
455            .expect("an adopted listener must accept")
456            .unwrap();
457        let mut said = [0_u8; 11];
458        tokio::time::timeout(bound, served.read_exact(&mut said))
459            .await
460            .expect("the bytes the client wrote must arrive")
461            .unwrap();
462        assert_eq!(&said, b"still here\n");
463        tokio::time::timeout(bound, client)
464            .await
465            .expect("the client task must finish")
466            .unwrap();
467    }
468
469    /// fails if the transport cannot carry bytes both ways on this platform.
470    /// The most basic thing this module claims, and the one that would break
471    /// silently if `ClientStream` and `ServerStream` were ever mismatched.
472    #[tokio::test]
473    async fn a_client_and_the_daemon_exchange_bytes_over_the_platform_transport() {
474        let dir = tempfile::tempdir().unwrap();
475        let addr = address(dir.path(), "roundtrip");
476        let mut listener = Listener::bind(&addr).unwrap();
477
478        let server = tokio::spawn(async move {
479            let mut stream = listener.accept().await.unwrap();
480            let mut buf = [0u8; 5];
481            stream.read_exact(&mut buf).await.unwrap();
482            stream.write_all(b"world").await.unwrap();
483            stream.flush().await.unwrap();
484            buf
485        });
486
487        let mut client = connect(&addr).await.unwrap();
488        client.write_all(b"hello").await.unwrap();
489        client.flush().await.unwrap();
490        let mut reply = [0u8; 5];
491        client.read_exact(&mut reply).await.unwrap();
492
493        assert_eq!(&server.await.unwrap(), b"hello");
494        assert_eq!(&reply, b"world");
495    }
496
497    /// fails if a second connection cannot be served after the first.
498    ///
499    /// This is the whole reason [`Listener::accept`] takes `&mut self`: a
500    /// named pipe server instance is consumed by its client, so an
501    /// implementation that forgot to create a replacement would serve
502    /// exactly one caller and then hang forever. On unix this is trivially
503    /// true and the test costs nothing; on Windows it is the load-bearing
504    /// assertion of this module.
505    #[tokio::test]
506    async fn the_listener_serves_more_than_one_connection() {
507        let dir = tempfile::tempdir().unwrap();
508        let addr = address(dir.path(), "sequential");
509        let mut listener = Listener::bind(&addr).unwrap();
510
511        let server = tokio::spawn(async move {
512            let mut seen = Vec::new();
513            for _ in 0..3 {
514                let mut stream = listener.accept().await.unwrap();
515                let mut byte = [0u8; 1];
516                stream.read_exact(&mut byte).await.unwrap();
517                seen.push(byte[0]);
518            }
519            seen
520        });
521
522        for tag in [1u8, 2, 3] {
523            let mut client = connect(&addr).await.unwrap();
524            client.write_all(&[tag]).await.unwrap();
525            client.flush().await.unwrap();
526            // Dropped here: the next iteration must get a fresh instance.
527        }
528
529        assert_eq!(server.await.unwrap(), vec![1, 2, 3]);
530    }
531
532    /// fails if dialing an address nothing is listening on reports success.
533    ///
534    /// The negative case the connect-retry loop could plausibly swallow: on
535    /// Windows a missing pipe is `ERROR_FILE_NOT_FOUND`, which must be
536    /// returned, while only `ERROR_PIPE_BUSY` is retried. A loop that
537    /// retried both would hang here instead of failing.
538    #[tokio::test]
539    async fn dialing_an_address_with_no_listener_fails_rather_than_hanging() {
540        let dir = tempfile::tempdir().unwrap();
541        let addr = address(dir.path(), "absent");
542
543        let result = tokio::time::timeout(std::time::Duration::from_secs(5), connect(&addr))
544            .await
545            .expect("connect must fail fast, not hang, when nothing is listening");
546
547        assert!(result.is_err(), "no listener must not read as a connection");
548    }
549
550    /// fails if two shepherds can own one control address at once.
551    ///
552    /// On Windows this is `first_pipe_instance(true)` doing the daemon's
553    /// mutual exclusion; a second `create` on the same name is refused by
554    /// the OS. On unix `bind` refuses an address already bound. Both
555    /// platforms must refuse, for the same operator-visible reason: two
556    /// daemons on one `$SHEP_HOME` would split the flock between them.
557    #[tokio::test]
558    async fn a_second_bind_on_the_same_address_is_refused() {
559        let dir = tempfile::tempdir().unwrap();
560        let addr = address(dir.path(), "exclusive");
561        let _first = Listener::bind(&addr).unwrap();
562
563        assert!(
564            Listener::bind(&addr).is_err(),
565            "a second daemon must not be able to bind the same control address"
566        );
567    }
568}