Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
node: type safe socket path
✗ CI failure Fintan Halpenny committed 3 months ago
commit f7d57415f5e1ee799021dea07d82d7773f4bbaff
parent 02318f199c6f29a2eede1f282e1f9b99927d27ec
1 failed (1 total) View logs
6 files changed +791 -76
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);
+
    }
+
}