use std::path::PathBuf;
#[cfg(unix)]
use crate::broker::lifecycle::sid::hash_to_16_hex;
use crate::broker::lifecycle::sid::SidError;
#[derive(Debug, thiserror::Error)]
pub enum PipePathError {
#[error("invalid name {name:?}: {reason}")]
InvalidName {
name: String,
reason: &'static str,
},
#[error("derived path exceeds {limit_label} ({len} > {max})")]
PathTooLong {
len: usize,
max: usize,
limit_label: &'static str,
},
#[error(transparent)]
Sid(#[from] SidError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PipePath {
pub windows: Option<String>,
pub unix: Option<PathBuf>,
}
pub const WINDOWS_MAX_PATH: usize = 260;
pub const MACOS_SUN_PATH_MAX: usize = 104;
pub const LINUX_SUN_PATH_MAX: usize = 108;
const PIPE_PREFIX: &str = "rpb-v1";
pub fn shared_broker_pipe(user_sid_hash: &str) -> Result<PipePath, PipePathError> {
validate_sid_hash(user_sid_hash)?;
build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-shared"))
}
pub fn private_broker_pipe(user_sid_hash: &str, service: &str) -> Result<PipePath, PipePathError> {
validate_sid_hash(user_sid_hash)?;
validate_service_name(service)?;
build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-svc-{service}"))
}
pub fn explicit_instance_pipe(user_sid_hash: &str, name: &str) -> Result<PipePath, PipePathError> {
validate_sid_hash(user_sid_hash)?;
validate_service_name(name)?; build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-inst-{name}"))
}
pub fn backend_pipe(user_sid_hash: &str, random128: &[u8; 16]) -> Result<PipePath, PipePathError> {
validate_sid_hash(user_sid_hash)?;
let mut suffix = String::with_capacity(32);
for b in random128 {
suffix.push(nibble_to_hex(b >> 4));
suffix.push(nibble_to_hex(b & 0x0F));
}
build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-be-{suffix}"))
}
pub fn validate_service_name(name: &str) -> Result<(), PipePathError> {
if name.is_empty() {
return Err(PipePathError::InvalidName {
name: name.into(),
reason: "service name must be at least 1 character",
});
}
if name.len() > 64 {
return Err(PipePathError::InvalidName {
name: name.into(),
reason: "service name must be 64 characters or fewer",
});
}
for c in name.chars() {
match c {
'a'..='z' | '0'..='9' | '-' => {}
'A'..='Z' => {
return Err(PipePathError::InvalidName {
name: name.into(),
reason: "uppercase letters are forbidden (case-only \
collisions with lowercase names would silently \
merge under Windows named-pipe semantics)",
});
}
_ => {
return Err(PipePathError::InvalidName {
name: name.into(),
reason: "only lowercase ASCII letters, digits, and '-' allowed",
});
}
}
}
Ok(())
}
pub fn validate_version(version: &str) -> Result<(), PipePathError> {
if version.is_empty() {
return Err(PipePathError::InvalidName {
name: version.into(),
reason: "version must not be empty",
});
}
let (core, prerelease) = match version.split_once('-') {
Some((core, tail)) => (core, Some(tail)),
None => (version, None),
};
let parts: Vec<&str> = core.split('.').collect();
if parts.len() != 3 {
return Err(PipePathError::InvalidName {
name: version.into(),
reason: "version core must be MAJOR.MINOR.PATCH",
});
}
for p in &parts {
if p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()) {
return Err(PipePathError::InvalidName {
name: version.into(),
reason: "MAJOR/MINOR/PATCH must be non-empty digits",
});
}
}
if let Some(tail) = prerelease {
if tail.is_empty() {
return Err(PipePathError::InvalidName {
name: version.into(),
reason: "pre-release suffix after '-' must not be empty",
});
}
for c in tail.chars() {
match c {
'a'..='z' | '0'..='9' | '.' => {}
_ => {
return Err(PipePathError::InvalidName {
name: version.into(),
reason: "pre-release tail allows only [a-z0-9.]",
});
}
}
}
}
Ok(())
}
fn validate_sid_hash(s: &str) -> Result<(), PipePathError> {
if s.len() != 16 {
return Err(PipePathError::InvalidName {
name: s.into(),
reason: "user_sid_hash must be exactly 16 hex characters",
});
}
for c in s.chars() {
if !(c.is_ascii_digit() || ('a'..='f').contains(&c)) {
return Err(PipePathError::InvalidName {
name: s.into(),
reason: "user_sid_hash must be lowercase hex",
});
}
}
Ok(())
}
#[inline]
fn nibble_to_hex(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
10..=15 => (b'a' + (n - 10)) as char,
_ => unreachable!("nibble out of range"),
}
}
fn build_pipe_path(name: &str) -> Result<PipePath, PipePathError> {
#[cfg(windows)]
{
let path = format!(r"\\.\pipe\{name}");
if path.len() > WINDOWS_MAX_PATH {
return Err(PipePathError::PathTooLong {
len: path.len(),
max: WINDOWS_MAX_PATH,
limit_label: "Windows MAX_PATH",
});
}
Ok(PipePath {
windows: Some(path),
unix: None,
})
}
#[cfg(unix)]
{
let dir = unix_broker_socket_dir();
let leaf = if cfg!(target_os = "macos") {
format!("{}.sock", hash_to_16_hex(name.as_bytes()))
} else {
format!("{name}.sock")
};
let candidate = dir.join(leaf);
let candidate_str = candidate.to_string_lossy();
let limit = if cfg!(target_os = "macos") {
MACOS_SUN_PATH_MAX
} else {
LINUX_SUN_PATH_MAX
};
let limit_label = if cfg!(target_os = "macos") {
"macOS sun_path"
} else {
"Linux sun_path"
};
if candidate_str.len() >= limit {
return Err(PipePathError::PathTooLong {
len: candidate_str.len(),
max: limit - 1,
limit_label,
});
}
Ok(PipePath {
windows: None,
unix: Some(candidate),
})
}
}
#[cfg(unix)]
fn unix_broker_socket_dir() -> PathBuf {
#[cfg(target_os = "macos")]
{
let uid = unsafe { libc::getuid() };
let tmp = std::env::var_os("TMPDIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"));
tmp.join(format!(".rp-{uid}"))
}
#[cfg(not(target_os = "macos"))]
{
if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR") {
PathBuf::from(d).join("running-process").join("broker")
} else {
let uid = unsafe { libc::getuid() };
PathBuf::from(format!("/tmp/running-process-{uid}/broker"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_HASH: &str = "0123456789abcdef";
#[test]
fn shared_broker_pipe_builds() {
let p = shared_broker_pipe(SAMPLE_HASH).expect("shared pipe should build");
#[cfg(windows)]
{
let w = p.windows.expect("windows form populated on Windows");
assert!(w.starts_with(r"\\.\pipe\rpb-v1-"));
assert!(w.ends_with("-shared"));
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let u = p.unix.expect("unix form populated on Unix");
let s = u.to_string_lossy();
assert!(s.contains("rpb-v1-"));
assert!(s.ends_with("-shared.sock"));
}
#[cfg(target_os = "macos")]
{
let u = p.unix.expect("unix form populated on macOS");
let s = u.to_string_lossy();
assert!(s.ends_with(".sock"));
}
}
#[test]
fn private_broker_pipe_rejects_uppercase() {
let err = private_broker_pipe(SAMPLE_HASH, "Zccache").unwrap_err();
match err {
PipePathError::InvalidName { .. } => {}
_ => panic!("expected InvalidName, got {err:?}"),
}
}
#[test]
fn validate_version_accepts_semver() {
validate_version("1.0.0").unwrap();
validate_version("1.11.20").unwrap();
validate_version("0.0.1-alpha.1").unwrap();
validate_version("2.3.4-rc.1.beta").unwrap();
}
#[test]
fn validate_version_rejects_invalid() {
assert!(validate_version("").is_err());
assert!(validate_version("1.0").is_err());
assert!(validate_version("1.0.0.0").is_err());
assert!(validate_version("1.0.0-").is_err());
assert!(validate_version("1.0.0-ALPHA").is_err()); assert!(validate_version("v1.0.0").is_err());
}
#[test]
fn backend_pipe_uses_hex_suffix() {
let p = backend_pipe(SAMPLE_HASH, &[0xABu8; 16]).expect("backend pipe");
let s = match (p.windows, p.unix) {
(Some(w), None) => w,
(None, Some(u)) => u.to_string_lossy().into_owned(),
_ => panic!("exactly one form must be populated"),
};
#[cfg(not(target_os = "macos"))]
{
assert!(s.contains("-be-"));
assert!(s.contains(&"ab".repeat(16)));
}
#[cfg(target_os = "macos")]
{
assert!(s.ends_with(".sock"));
}
}
}