Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
node: type safe socket path
Draft fintohaps opened 3 months ago

Provide a type-safe socket path that captures the invariants expected for the control socket on each OS.

The common invariant for each OS is the length of the path name, in bytes.

This is then used to construct the ControlSocket used in radicle-node.

6 files changed +791 -76 02318f19 c92f8198
modified crates/radicle-node/src/control.rs
@@ -2,7 +2,6 @@
use std::io::prelude::*;
use std::io::BufReader;
use std::io::LineWriter;
-
use std::path::PathBuf;
use std::{io, net, time};

#[cfg(unix)]
@@ -24,10 +23,6 @@ const MAX_TIMEOUT: time::Duration = time::Duration::MAX;

#[derive(thiserror::Error, Debug)]
pub enum Error {
-
    #[error("failed to bind control socket listener: {0}")]
-
    Bind(io::Error),
-
    #[error("invalid socket path specified: {0}")]
-
    InvalidPath(PathBuf),
    #[error("node: {0}")]
    Node(#[from] runtime::HandleError),
}
modified crates/radicle-node/src/runtime.rs
@@ -1,15 +1,12 @@
+
mod socket;
+
use socket::ControlSocket;
+

pub mod handle;
pub mod thread;

use std::fmt::Debug;
-
use std::path::PathBuf;
use std::{fs, io, net};

-
#[cfg(unix)]
-
use std::os::unix::net::UnixListener as Listener;
-
#[cfg(windows)]
-
use winpipe::WinListener as Listener;
-

use crossbeam_channel as chan;
use cyphernet::Ecdh;
use radicle::cob::migrate;
@@ -78,16 +75,12 @@ pub enum Error {
    /// An I/O error.
    #[error("i/o error: {0}")]
    Io(#[from] io::Error),
-
    /// A control socket error.
-
    #[error("control socket error: {0}")]
-
    Control(#[from] control::Error),
-
    /// Another node is already running.
-
    #[error(
-
        "another node appears to be running; \
-
        if this isn't the case, delete the socket file at '{0}' \
-
        and restart the node"
-
    )]
-
    AlreadyRunning(PathBuf),
+
    /// Failed to construct a valid socket path.
+
    #[error(transparent)]
+
    ControlSocketPath(#[from] socket::error::SocketPath),
+
    /// Failed to construct the control socket.
+
    #[error(transparent)]
+
    Control(#[from] socket::error::ControlSocket),
    /// A git version error.
    #[error("git version error: {0}")]
    GitVersion(#[from] git::VersionError),
@@ -99,14 +92,6 @@ impl From<service::Error> for Error {
    }
}

-
/// Wraps a [`Listener`] but tracks its origin.
-
pub enum ControlSocket {
-
    /// The listener was created by binding to it.
-
    Bound(Listener, PathBuf),
-
    /// The listener was received via socket activation.
-
    Received(Listener),
-
}
-

/// Holds join handles to the client threads, as well as a client handle.
pub struct Runtime {
    pub id: NodeId,
@@ -258,7 +243,11 @@ impl Runtime {
                policies_db: home.node().join(node::POLICIES_DB_FILE),
            },
        )?;
-
        let control = Self::bind(home.socket())?;
+

+
        let control = {
+
            let socket_path = socket::SocketPath::new(home.socket())?;
+
            ControlSocket::bind(socket_path)?
+
        };

        Ok(Runtime {
            id,
@@ -321,50 +310,4 @@ impl Runtime {

        Ok(())
    }
-

-
    #[cfg(all(feature = "systemd", target_os = "linux"))]
-
    fn receive_listener() -> Option<Listener> {
-
        let fd = match radicle_systemd::listen::fd("control") {
-
            Ok(Some(fd)) => fd,
-
            Ok(None) => return None,
-
            Err(err) => {
-
                log::error!(target: "node", "Error receiving listener from systemd: {err}");
-
                return None;
-
            }
-
        };
-

-
        let socket: socket2::Socket = unsafe { std::os::fd::FromRawFd::from_raw_fd(fd) };
-

-
        let domain = match socket.domain() {
-
            Ok(domain) => domain,
-
            Err(err) => {
-
                log::error!(target: "node", "Error receiving listener from systemd when inspecting domain of socket: {err}");
-
                return None;
-
            }
-
        };
-

-
        if domain != socket2::Domain::UNIX {
-
            log::error!(target: "node", "Dropping listener received from systemd: Domain is not AF_UNIX.");
-
            return None;
-
        }
-

-
        Some(Listener::from(socket))
-
    }
-

-
    fn bind(path: PathBuf) -> Result<ControlSocket, Error> {
-
        #[cfg(all(feature = "systemd", target_os = "linux"))]
-
        {
-
            if let Some(listener) = Self::receive_listener() {
-
                log::info!(target: "node", "Received control socket.");
-
                return Ok(ControlSocket::Received(listener));
-
            }
-
        }
-

-
        log::info!(target: "node", "Binding control socket {}..", &path.display());
-
        match Listener::bind(&path) {
-
            Ok(sock) => Ok(ControlSocket::Bound(sock, path)),
-
            Err(err) if err.kind() == io::ErrorKind::AddrInUse => Err(Error::AlreadyRunning(path)),
-
            Err(err) => Err(err.into()),
-
        }
-
    }
}
added crates/radicle-node/src/runtime/socket.rs
@@ -0,0 +1,186 @@
+
//! Type-safe socket path handling with platform-specific validation.
+
//!
+
//! This module provides newtype wrappers around `PathBuf` that enforce
+
//! platform-specific constraints for Unix domain sockets and Windows named
+
//! pipes.
+
//!
+
//! # Platform Constraints
+
//!
+
//! ## Unix Domain Sockets (`sun_path` limits)
+
//!
+
//! The maximum length of the socket path, referred to as `sun_path`, can differ
+
//! on different operating systems.
+
//!
+
//! The following are the known lengths:
+
//!
+
//! - Linux: [108 bytes](linux-length) (see "Address Format")
+
//! - MacOS/iOS: [104 bytes](macos-length)
+
//! - FreeBSD: [104 bytes](free-bsd) (see "Addressing")
+
//! - NetBSD: [104 bytes](net-bsd) (see "Addressing")
+
//! - OpenBSD: [104 bytes](open-bsd) (see "Addressing")
+
//! - DragonFly: [104 bytes](free-bsd) (derived from FreeBSD)
+
//! - Android: [108 bytes](linux-length) (same as Linux)
+
//!
+
//! In the case of a missing OS case, a conservative value of `104 bytes` is
+
//! used for the maximum length.
+
//!
+
//! ## Windows Named Pipes
+
//!
+
//! Windows named pipes have the following [constraints][windows]:
+
//! - Maximum path length: up to 256 characters
+
//! - Expected format: `\\.\pipe\<pipename>` or `\\<server>\pipe\<pipename>`
+
//! - The name must exclude backslashes
+
//!
+
//! [linux]: https://man7.org/linux/man-pages/man7/unix.7.html
+
//! [macos]: https://github.com/apple-oss-distributions/xnu/blob/f6217f891ac0bb64f3d375211650a4c1ff8ca1ea/bsd/man/man4/unix.4
+
//! [free-bsd]: https://man.freebsd.org/cgi/man.cgi?unix(4)
+
//! [net-bsd]: https://man.netbsd.org/unix.4
+
//! [open-bsd]: https://man.openbsd.org/unix.4
+
//! [windows]: https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-createnamedpipew#parameters
+

+
#[cfg(unix)]
+
mod unix;
+
#[cfg(windows)]
+
mod windows;
+

+
pub mod error;
+

+
use std::fmt;
+
use std::io;
+
use std::path::{Path, PathBuf};
+

+
#[cfg(unix)]
+
use std::os::unix::net::UnixListener as Listener;
+
#[cfg(windows)]
+
use winpipe::WinListener as Listener;
+

+
/// Wraps a [`Listener`] but tracks its origin.
+
pub enum ControlSocket {
+
    /// The listener was created by binding to it.
+
    Bound(Listener, SocketPath),
+
    /// The listener was received via socket activation.
+
    Received(Listener),
+
}
+

+
impl ControlSocket {
+
    pub fn bind(path: SocketPath) -> Result<Self, error::ControlSocket> {
+
        #[cfg(all(feature = "systemd", target_os = "linux"))]
+
        {
+
            if let Some(listener) = Self::receive_listener() {
+
                log::info!(target: "node", "Received control socket.");
+
                return Ok(ControlSocket::Received(listener));
+
            }
+
        }
+

+
        // log::info!(target: "node", "Binding control socket {}..", &path.display());
+
        match Listener::bind(&path) {
+
            Ok(sock) => Ok(ControlSocket::Bound(sock, path)),
+
            Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
+
                Err(error::ControlSocket::AlreadyRunning {
+
                    path: path.into_path_buf(),
+
                })
+
            }
+
            Err(err) => Err(error::ControlSocket::Bind {
+
                path: path.into_path_buf(),
+
                source: err,
+
            }),
+
        }
+
    }
+

+
    #[cfg(all(feature = "systemd", target_os = "linux"))]
+
    fn receive_listener() -> Option<Listener> {
+
        let fd = match radicle_systemd::listen::fd("control") {
+
            Ok(Some(fd)) => fd,
+
            Ok(None) => return None,
+
            Err(err) => {
+
                log::error!(target: "node", "Error receiving listener from systemd: {err}");
+
                return None;
+
            }
+
        };
+

+
        let socket: socket2::Socket = unsafe { std::os::fd::FromRawFd::from_raw_fd(fd) };
+

+
        let domain = match socket.domain() {
+
            Ok(domain) => domain,
+
            Err(err) => {
+
                log::error!(target: "node", "Error receiving listener from systemd when inspecting domain of socket: {err}");
+
                return None;
+
            }
+
        };
+

+
        if domain != socket2::Domain::UNIX {
+
            log::error!(target: "node", "Dropping listener received from systemd: Domain is not AF_UNIX.");
+
            return None;
+
        }
+

+
        Some(Listener::from(socket))
+
    }
+
}
+

+
/// A cross-platform socket path that works on both Unix and Windows.
+
///
+
/// On Unix systems, this wraps a [`UnixSocketPath`].
+
/// On Windows, this wraps a [`WindowsPipePath`].
+
///
+
/// This type provides a unified API for socket paths across platforms.
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+
pub enum SocketPath {
+
    #[cfg(unix)]
+
    Unix(unix::UnixSocketPath),
+
    #[cfg(windows)]
+
    Windows(windows::WindowsPipePath),
+
}
+

+
impl SocketPath {
+
    /// Creates a new socket path appropriate for the current platform.
+
    ///
+
    /// On Unix, this creates a `UnixSocketPath`.
+
    /// On Windows, this creates a `WindowsPipePath`.
+
    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, error::SocketPath> {
+
        #[cfg(unix)]
+
        {
+
            unix::UnixSocketPath::new(path).map(SocketPath::Unix)
+
        }
+
        #[cfg(windows)]
+
        {
+
            windows::WindowsPipePath::new(path).map(SocketPath::Windows)
+
        }
+
    }
+

+
    /// Returns the path as a `Path` reference.
+
    pub fn as_path(&self) -> &Path {
+
        match self {
+
            #[cfg(unix)]
+
            SocketPath::Unix(p) => p.as_path(),
+
            #[cfg(windows)]
+
            SocketPath::Windows(p) => p.as_path(),
+
        }
+
    }
+

+
    /// Consumes the socket path and returns the inner `PathBuf`.
+
    pub fn into_path_buf(self) -> PathBuf {
+
        match self {
+
            #[cfg(unix)]
+
            SocketPath::Unix(p) => p.into_path_buf(),
+
            #[cfg(windows)]
+
            SocketPath::Windows(p) => p.into_path_buf(),
+
        }
+
    }
+
}
+

+
impl AsRef<Path> for SocketPath {
+
    fn as_ref(&self) -> &Path {
+
        self.as_path()
+
    }
+
}
+

+
impl fmt::Display for SocketPath {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            #[cfg(unix)]
+
            SocketPath::Unix(p) => write!(f, "{}", p),
+
            #[cfg(windows)]
+
            SocketPath::Windows(p) => write!(f, "{}", p),
+
        }
+
    }
+
}
added crates/radicle-node/src/runtime/socket/error.rs
@@ -0,0 +1,48 @@
+
use std::io;
+
use std::path::PathBuf;
+

+
use thiserror::Error;
+

+
#[derive(Debug, Error)]
+
pub enum ControlSocket {
+
    /// Another node is already running.
+
    #[error(
+
        "another node appears to be running; \
+
        if this isn't the case, delete the socket file at '{path}' \
+
        and restart the node"
+
    )]
+
    AlreadyRunning { path: PathBuf },
+
    #[error("failed to bind listener to '{path}': {source}")]
+
    Bind { path: PathBuf, source: io::Error },
+
}
+

+
/// Errors that can occur during socket path validation.
+
#[derive(Debug, Error)]
+
pub enum SocketPath {
+
    /// The path exceeds the maximum allowed length for the platform.
+
    #[error("socket path `{path}` too long: {length} bytes exceeds maximum of {max} bytes")]
+
    PathTooLong {
+
        path: String,
+
        length: usize,
+
        max: usize,
+
    },
+
    /// The path contains a null byte, which is not allowed.
+
    #[error("socket path contains null byte at position {position}")]
+
    ContainsNullByte { position: usize },
+
    /// The path is empty.
+
    #[error("socket path cannot be empty")]
+
    EmptyPath,
+

+
    /// The path contains invalid UTF-8.
+
    #[cfg(windows)]
+
    #[error("socket path '{path}' contains invalid UTF-8")]
+
    InvalidUtf8 { path: PathBuf },
+
    /// The path does not follow the required Windows named pipe format.
+
    #[cfg(windows)]
+
    #[error("invalid Windows pipe format '{pipe_name}': {reason}")]
+
    InvalidPipeFormat { reason: &'static str },
+
    /// The pipe name portion contains a backslash.
+
    #[cfg(windows)]
+
    #[error("pipe name '{pipe_name}' cannot contain backslashes")]
+
    PipeNameContainsBackslash { pipe_name: String },
+
}
added crates/radicle-node/src/runtime/socket/unix.rs
@@ -0,0 +1,195 @@
+
use std::{
+
    ffi::OsStr,
+
    fmt,
+
    path::{Path, PathBuf},
+
};
+

+
use super::error;
+

+
#[cfg(any(target_os = "linux", target_os = "android"))]
+
pub const MAX_PATH_LEN: usize = 108;
+

+
#[cfg(any(
+
    target_os = "macos",
+
    target_os = "ios",
+
    target_os = "freebsd",
+
    target_os = "netbsd",
+
    target_os = "openbsd",
+
    target_os = "dragonfly"
+
))]
+
pub const MAX_PATH_LEN: usize = 104;
+

+
// Fallback for other Unix-like systems, use conservative that matches BSD
+
// family.
+
#[cfg(all(
+
    unix,
+
    not(any(
+
        target_os = "linux",
+
        target_os = "android",
+
        target_os = "macos",
+
        target_os = "ios",
+
        target_os = "freebsd",
+
        target_os = "netbsd",
+
        target_os = "openbsd",
+
        target_os = "dragonfly",
+
    ))
+
))]
+
pub const MAX_PATH_LEN: usize = 104;
+

+
/// A validated Unix domain socket path.
+
///
+
/// This type guarantees that the contained path:
+
/// - Does not exceed the platform's `sun_path` limit
+
/// - Does not contain embedded null bytes
+
/// - Is not empty
+
///
+
/// # Example
+
///
+
/// ```ignore, rust
+
/// let path = UnixSocketPath::new("/tmp/my.sock")?;
+
/// let listener = std::os::unix::net::UnixListener::bind(path.as_path())?;
+
/// ```
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+
pub struct UnixSocketPath {
+
    inner: PathBuf,
+
    /// Cached byte length for quick access
+
    byte_len: usize,
+
}
+

+
impl UnixSocketPath {
+
    /// The maximum number of bytes allowed in a socket path (excluding null terminator).
+
    pub const MAX_PATH_BYTES: usize = MAX_PATH_LEN - 1;
+

+
    /// Creates a new `UnixSocketPath` after validating the path.
+
    ///
+
    /// # Errors
+
    ///
+
    /// Returns an error if:
+
    /// - The path is empty
+
    /// - The path exceeds `MAX_PATH_BYTES`
+
    /// - The path contains embedded null bytes
+
    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, error::SocketPath> {
+
        let path = path.as_ref();
+
        Self::from_path_buf(path.to_path_buf())
+
    }
+

+
    /// Creates a new `UnixSocketPath` from a `PathBuf`.
+
    ///
+
    /// # Errors
+
    ///
+
    /// See [`UnixSocketPath::new`].
+
    pub fn from_path_buf(path: PathBuf) -> Result<Self, error::SocketPath> {
+
        use std::os::unix::ffi::OsStrExt;
+

+
        let bytes = path.as_os_str().as_bytes();
+

+
        // Check for empty path
+
        if bytes.is_empty() {
+
            return Err(error::SocketPath::EmptyPath);
+
        }
+

+
        // Check for null bytes
+
        if let Some(pos) = bytes.iter().position(|&b| b == 0) {
+
            return Err(error::SocketPath::ContainsNullByte { position: pos });
+
        }
+

+
        // Check length (must leave room for null terminator)
+
        if bytes.len() > Self::MAX_PATH_BYTES {
+
            return Err(error::SocketPath::PathTooLong {
+
                path: path.to_string_lossy().to_string(),
+
                length: bytes.len(),
+
                max: Self::MAX_PATH_BYTES,
+
            });
+
        }
+

+
        Ok(Self {
+
            byte_len: bytes.len(),
+
            inner: path,
+
        })
+
    }
+

+
    /// Returns the path as a `Path` reference.
+
    #[inline]
+
    pub fn as_path(&self) -> &Path {
+
        &self.inner
+
    }
+

+
    /// Returns the path as an `OsStr` reference.
+
    #[inline]
+
    pub fn as_os_str(&self) -> &OsStr {
+
        self.inner.as_os_str()
+
    }
+

+
    /// Consumes the `UnixSocketPath` and returns the inner `PathBuf`.
+
    #[inline]
+
    pub fn into_path_buf(self) -> PathBuf {
+
        self.inner
+
    }
+
}
+

+
impl AsRef<Path> for UnixSocketPath {
+
    #[inline]
+
    fn as_ref(&self) -> &Path {
+
        &self.inner
+
    }
+
}
+

+
impl AsRef<OsStr> for UnixSocketPath {
+
    #[inline]
+
    fn as_ref(&self) -> &OsStr {
+
        self.inner.as_os_str()
+
    }
+
}
+

+
impl std::ops::Deref for UnixSocketPath {
+
    type Target = Path;
+

+
    #[inline]
+
    fn deref(&self) -> &Self::Target {
+
        &self.inner
+
    }
+
}
+

+
impl fmt::Display for UnixSocketPath {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(f, "{}", self.inner.display())
+
    }
+
}
+

+
impl TryFrom<PathBuf> for UnixSocketPath {
+
    type Error = error::SocketPath;
+

+
    fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
+
        Self::from_path_buf(path)
+
    }
+
}
+

+
impl TryFrom<&Path> for UnixSocketPath {
+
    type Error = error::SocketPath;
+

+
    fn try_from(path: &Path) -> Result<Self, Self::Error> {
+
        Self::new(path)
+
    }
+
}
+

+
impl TryFrom<String> for UnixSocketPath {
+
    type Error = error::SocketPath;
+

+
    fn try_from(path: String) -> Result<Self, Self::Error> {
+
        Self::new(path)
+
    }
+
}
+

+
impl TryFrom<&str> for UnixSocketPath {
+
    type Error = error::SocketPath;
+

+
    fn try_from(path: &str) -> Result<Self, Self::Error> {
+
        Self::new(path)
+
    }
+
}
+

+
impl From<UnixSocketPath> for PathBuf {
+
    fn from(path: UnixSocketPath) -> Self {
+
        path.inner
+
    }
+
}
added crates/radicle-node/src/runtime/socket/windows.rs
@@ -0,0 +1,348 @@
+
use std::ffi::OsStr;
+
use std::fmt;
+
use std::path::{Path, PathBuf};
+

+
use super::error;
+

+
const MAX_PATH_LEN: usize = 256;
+

+
/// The required prefix for local Windows named pipes.
+
const LOCAL_PIPE_PREFIX: &str = r"\\.\pipe\";
+

+
/// A validated local Windows named pipe path.
+
///
+
/// This type guarantees that the contained path:
+
/// - Follows the format `\\.\pipe\<pipename>`
+
/// - Does not exceed 256 characters total
+
/// - The pipe name portion does not contain backslashes
+
/// - Contains valid UTF-8
+
///
+
/// # Example
+
///
+
/// ```ignore, rust
+
/// // Create from just the pipe name (recommended)
+
/// let path = WindowsPipePath::new("myapp")?;
+
/// assert_eq!(path.as_str(), r"\\.\pipe\myapp");
+
///
+
/// // Or validate a full path
+
/// let path = WindowsPipePath::from_full_path(r"\\.\pipe\myapp")?;
+
/// ```
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+
pub struct WindowsPipePath {
+
    inner: PathBuf,
+
    /// The pipe name portion (after `\\.\pipe\`)
+
    pipe_name: String,
+
}
+

+
impl WindowsPipePath {
+
    /// Creates a new `WindowsPipePath` from a pipe name.
+
    ///
+
    /// This automatically prepends `\\.\pipe\` to the name.
+
    ///
+
    /// # Example
+
    ///
+
    /// ```ignore, rust
+
    /// let path = WindowsPipePath::new("myapp")?;
+
    /// assert_eq!(path.as_str(), r"\\.\pipe\myapp");
+
    /// ```
+
    ///
+
    /// # Errors
+
    ///
+
    /// Returns an error if:
+
    /// - The pipe name is empty
+
    /// - The pipe name contains backslashes
+
    /// - The resulting full path exceeds 256 characters
+
    /// - The pipe name contains null bytes
+
    pub fn new<S: AsRef<str>>(pipe_name: S) -> Result<Self, error::SocketPath> {
+
        let name = pipe_name.as_ref();
+
        Self::validate_pipe_name(name)?;
+

+
        let path = format!(r"\\.\pipe\{name}");
+
        let path_length = path.len();
+
        if path_length > MAX_PATH_LEN {
+
            return Err(error::SocketPath::PathTooLong {
+
                path,
+
                length: path_length,
+
                max: MAX_PATH_LEN,
+
            });
+
        }
+

+
        Ok(Self {
+
            inner: PathBuf::from(&path),
+
            pipe_name: name.to_string(),
+
        })
+
    }
+

+
    /// Creates a `WindowsPipePath` from a full path string.
+
    ///
+
    /// The path must be in the format `\\.\pipe\<pipename>`.
+
    ///
+
    /// # Example
+
    ///
+
    /// ```rust
+
    /// let path = WindowsPipePath::from_full_path(r"\\.\pipe\myapp")?;
+
    /// assert_eq!(path.pipe_name(), "myapp");
+
    /// ```
+
    ///
+
    /// # Errors
+
    ///
+
    /// Returns an error if:
+
    /// - The path is empty
+
    /// - The path doesn't start with `\\.\pipe\`
+
    /// - The path exceeds 256 characters
+
    /// - The pipe name portion is empty or contains backslashes
+
    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, error::SocketPath> {
+
        let path = path.as_ref();
+
        let path_str = path.to_str().ok_or(error::SocketPath::InvalidUtf8 {
+
            path: path.into_path_buf(),
+
        })?;
+

+
        if path_str.is_empty() {
+
            return Err(error::SocketPath::EmptyPath);
+
        }
+

+
        if path_str.len() > MAX_PATH_LEN {
+
            return Err(error::SocketPath::PathTooLong {
+
                length: path_str.len(),
+
                max: MAX_PATH_LEN,
+
            });
+
        }
+

+
        // Normalize forward slashes to backslashes for comparison
+
        let normalized = path_str.replace('/', "\\");
+

+
        let pipe_name = normalized
+
            .to_lowercase()
+
            .strip_prefix(LOCAL_PIPE_PREFIX.to_lowercase())
+
            .ok_or(error::SocketPath::InvalidPipeFormat {
+
                reason: "path must start with \\\\.\\pipe\\",
+
            })?;
+

+
        Self::validate_pipe_name(pipe_name)?;
+

+
        Ok(Self {
+
            inner: path.to_path_buf(),
+
            pipe_name: pipe_name.to_string(),
+
        })
+
    }
+

+
    fn validate_pipe_name(name: &str) -> Result<(), error::SocketPath> {
+
        if name.is_empty() {
+
            return Err(error::SocketPath::InvalidPipeFormat {
+
                reason: "pipe name cannot be empty",
+
            });
+
        }
+

+
        if name.contains(['\\', '/']) {
+
            return Err(error::SocketPath::PipeNameContainsBackslash {
+
                pipe_name: name.to_string(),
+
            });
+
        }
+

+
        if let Some(pos) = name.bytes().position(|b| b == 0) {
+
            return Err(error::SocketPath::ContainsNullByte { position: pos });
+
        }
+

+
        Ok(())
+
    }
+

+
    /// Returns the full path as a string slice.
+
    #[inline]
+
    pub fn as_str(&self) -> &str {
+
        // Safe because we validated UTF-8 during construction
+
        self.inner.to_str().unwrap()
+
    }
+

+
    /// Returns the full path as a `Path` reference.
+
    #[inline]
+
    pub fn as_path(&self) -> &Path {
+
        &self.inner
+
    }
+

+
    /// Returns just the pipe name, without the `\\.\pipe\` prefix.
+
    #[inline]
+
    pub fn pipe_name(&self) -> &str {
+
        &self.pipe_name
+
    }
+

+
    /// Consumes the `WindowsPipePath` and returns the inner `PathBuf`.
+
    #[inline]
+
    pub fn into_path_buf(self) -> PathBuf {
+
        self.inner
+
    }
+
}
+

+
impl AsRef<Path> for WindowsPipePath {
+
    #[inline]
+
    fn as_ref(&self) -> &Path {
+
        &self.inner
+
    }
+
}
+

+
impl AsRef<OsStr> for WindowsPipePath {
+
    #[inline]
+
    fn as_ref(&self) -> &OsStr {
+
        self.inner.as_os_str()
+
    }
+
}
+

+
impl AsRef<str> for WindowsPipePath {
+
    #[inline]
+
    fn as_ref(&self) -> &str {
+
        self.as_str()
+
    }
+
}
+

+
impl std::ops::Deref for WindowsPipePath {
+
    type Target = Path;
+

+
    #[inline]
+
    fn deref(&self) -> &Self::Target {
+
        &self.inner
+
    }
+
}
+

+
impl fmt::Display for WindowsPipePath {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(f, "{}", self.as_str())
+
    }
+
}
+

+
impl TryFrom<PathBuf> for WindowsPipePath {
+
    type Error = error::SocketPath;
+

+
    fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
+
        Self::from_path(path)
+
    }
+
}
+

+
impl TryFrom<&Path> for WindowsPipePath {
+
    type Error = error::SocketPath;
+

+
    fn try_from(path: &Path) -> Result<Self, Self::Error> {
+
        Self::from_path(path)
+
    }
+
}
+

+
impl TryFrom<String> for WindowsPipePath {
+
    type Error = error::SocketPath;
+

+
    fn try_from(path: String) -> Result<Self, Self::Error> {
+
        Self::from_path(path)
+
    }
+
}
+

+
impl TryFrom<&str> for WindowsPipePath {
+
    type Error = error::SocketPath;
+

+
    fn try_from(path: &str) -> Result<Self, Self::Error> {
+
        Self::from_path(path)
+
    }
+
}
+

+
impl From<WindowsPipePath> for PathBuf {
+
    fn from(path: WindowsPipePath) -> Self {
+
        path.inner
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::*;
+

+
    #[test]
+
    fn test_new_creates_full_path() {
+
        let path = WindowsPipePath::new("myapp").unwrap();
+
        assert_eq!(path.as_str(), r"\\.\pipe\myapp");
+
        assert_eq!(path.pipe_name(), "myapp");
+
    }
+

+
    #[test]
+
    fn test_from_full_path_valid() {
+
        let path = WindowsPipePath::from_path(r"\\.\pipe\myapp").unwrap();
+
        assert_eq!(path.pipe_name(), "myapp");
+
    }
+

+
    #[test]
+
    fn test_from_full_path_with_forward_slashes() {
+
        let path = WindowsPipePath::from_path("//./pipe/myapp").unwrap();
+
        assert_eq!(path.pipe_name(), "myapp");
+
    }
+

+
    #[test]
+
    fn test_empty_name_rejected() {
+
        let result = WindowsPipePath::new("");
+
        assert!(matches!(
+
            result,
+
            Err(error::SocketPath::InvalidPipeFormat { .. })
+
        ));
+
    }
+

+
    #[test]
+
    fn test_empty_path_rejected() {
+
        let result = WindowsPipePath::from_path("");
+
        assert!(matches!(result, Err(error::SocketPath::EmptyPath)));
+
    }
+

+
    #[test]
+
    fn test_invalid_prefix_rejected() {
+
        let result = WindowsPipePath::from_path(r"C:\temp\pipe");
+
        assert!(matches!(
+
            result,
+
            Err(error::SocketPath::InvalidPipeFormat { .. })
+
        ));
+
    }
+

+
    #[test]
+
    fn test_remote_pipe_rejected() {
+
        let result = WindowsPipePath::from_path(r"\\server\pipe\myapp");
+
        assert!(matches!(
+
            result,
+
            Err(error::SocketPath::InvalidPipeFormat { .. })
+
        ));
+
    }
+

+
    #[test]
+
    fn test_pipe_name_with_backslash_rejected() {
+
        let result = WindowsPipePath::new(r"my\app");
+
        assert!(matches!(
+
            result,
+
            Err(error::SocketPath::PipeNameContainsBackslash)
+
        ));
+
    }
+

+
    #[test]
+
    fn test_pipe_name_with_forward_slash_rejected() {
+
        let result = WindowsPipePath::new("my/app");
+
        assert!(matches!(
+
            result,
+
            Err(error::SocketPath::PipeNameContainsBackslash)
+
        ));
+
    }
+

+
    #[test]
+
    fn test_path_too_long() {
+
        let long_name = "x".repeat(WindowsPipePath::max_pipe_name_chars() + 1);
+
        let result = WindowsPipePath::new(&long_name);
+
        assert!(matches!(result, Err(error::SocketPath::PathTooLong { .. })));
+
    }
+

+
    #[test]
+
    fn test_max_length_name_accepted() {
+
        let max_name = "x".repeat(WindowsPipePath::max_pipe_name_chars());
+
        let result = WindowsPipePath::new(&max_name);
+
        assert!(result.is_ok());
+
    }
+

+
    #[test]
+
    fn test_display() {
+
        let path = WindowsPipePath::new("myapp").unwrap();
+
        assert_eq!(format!("{}", path), r"\\.\pipe\myapp");
+
    }
+

+
    #[test]
+
    fn test_max_pipe_name_chars() {
+
        // Prefix is "\\.\pipe\" = 9 characters
+
        assert_eq!(WindowsPipePath::max_pipe_name_chars(), 256 - 9);
+
    }
+
}