running_process/broker/lifecycle/names.rs
1//! Canonical v1 broker pipe-name derivation.
2//!
3//! Phase 1 of #228 (issue #230). Every name is derived from the
4//! caller's [`user_sid_hash`](super::sid::user_sid_hash) plus a few
5//! frozen string templates. The Windows form is a named pipe
6//! (`\\.\pipe\...`); the Unix form is a filesystem socket path under
7//! the broker shadow directory.
8//!
9//! The four canonical names exposed here are:
10//!
11//! | Function | Purpose |
12//! |---------------------------|---------------------------------------------------------------------|
13//! | [`shared_broker_pipe`] | Single per-user broker that serves every service together. |
14//! | [`private_broker_pipe`] | Service-isolated broker (e.g. one zccache instance only). |
15//! | [`explicit_instance_pipe`]| Hand-named broker for tests/dev/multi-instance scenarios. |
16//! | [`backend_pipe`] | The per-backend handle the broker hands a client after negotiation. |
17//!
18//! ## Validation
19//!
20//! Service names must match `[a-z0-9-]{1,64}`. Version strings must
21//! match a semver-like `^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.]+)?$`.
22//! Explicit instance names match `[a-z0-9-]{1,64}`. Case-only
23//! collisions (`Zccache` vs `zccache`) are rejected with
24//! [`PipePathError::InvalidName`] because Windows named pipes are
25//! case-insensitive and silently coalescing would let a malicious
26//! caller hijack a legitimate broker.
27//!
28//! ## Length limits
29//!
30//! - Windows `\\.\pipe\` names without the `\\?\` long-path prefix
31//! are capped by `MAX_PATH = 260` characters.
32//! - macOS `sun_path` (the path field of `struct sockaddr_un`) is 104
33//! bytes. The Unix path returned here is validated to stay under
34//! that bound after combining `shadow_dir() + "/broker/" + name +
35//! ".sock"`.
36
37use std::path::PathBuf;
38
39#[cfg(unix)]
40use crate::broker::lifecycle::sid::hash_to_16_hex;
41use crate::broker::lifecycle::sid::SidError;
42
43/// Errors that prevent computing a valid pipe path.
44#[derive(Debug, thiserror::Error)]
45pub enum PipePathError {
46 /// A name argument failed regex validation.
47 #[error("invalid name {name:?}: {reason}")]
48 InvalidName {
49 /// The offending input.
50 name: String,
51 /// Why it was rejected.
52 reason: &'static str,
53 },
54
55 /// The derived path exceeds a platform-specific bound.
56 #[error("derived path exceeds {limit_label} ({len} > {max})")]
57 PathTooLong {
58 /// Length we tried to produce.
59 len: usize,
60 /// Platform-specific cap.
61 max: usize,
62 /// "Windows MAX_PATH" / "macOS sun_path" / etc.
63 limit_label: &'static str,
64 },
65
66 /// Failure to compute the per-user SID hash.
67 #[error(transparent)]
68 Sid(#[from] SidError),
69}
70
71/// A pipe address in platform-neutral form.
72///
73/// Exactly one of [`Self::windows`] or [`Self::unix`] is populated on
74/// any given host. The other field is `None`. Callers select the
75/// active platform's value via `cfg(windows)` / `cfg(unix)` blocks.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct PipePath {
78 /// Windows named-pipe path (e.g. `\\.\pipe\rpb-v1-abc-shared`).
79 pub windows: Option<String>,
80 /// Unix domain socket path (e.g.
81 /// `/run/user/1000/running-process/broker/rpb-v1-abc-shared.sock`).
82 pub unix: Option<PathBuf>,
83}
84
85/// Windows MAX_PATH ceiling without the `\\?\` long-path prefix.
86pub const WINDOWS_MAX_PATH: usize = 260;
87
88/// macOS `sun_path` field ceiling. POSIX requires at least 92;
89/// Darwin's `struct sockaddr_un` actually has 104.
90pub const MACOS_SUN_PATH_MAX: usize = 104;
91
92/// Linux `sun_path` field ceiling. glibc defines it as 108.
93pub const LINUX_SUN_PATH_MAX: usize = 108;
94
95/// Compile-time prefix every broker pipe shares. Encodes the v1
96/// envelope version and the "running-process broker" namespace so
97/// pipe names cannot accidentally collide with anything else under
98/// `\\.\pipe\` or `shadow_dir()/broker/`.
99const PIPE_PREFIX: &str = "rpb-v1";
100
101/// Compute the shared-broker pipe address.
102///
103/// The shared broker is the default: one instance per user that fans
104/// every service request out to the right backend.
105pub fn shared_broker_pipe(user_sid_hash: &str) -> Result<PipePath, PipePathError> {
106 validate_sid_hash(user_sid_hash)?;
107 build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-shared"))
108}
109
110/// Compute the private-broker pipe address for a single service.
111///
112/// Service names must match `[a-z0-9-]{1,64}`.
113pub fn private_broker_pipe(
114 user_sid_hash: &str,
115 service: &str,
116) -> Result<PipePath, PipePathError> {
117 validate_sid_hash(user_sid_hash)?;
118 validate_service_name(service)?;
119 build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-svc-{service}"))
120}
121
122/// Compute the explicit-instance broker pipe address.
123///
124/// `name` must match `[a-z0-9-]{1,64}` and is otherwise unrestricted.
125/// Used for tests and multi-instance dev setups.
126pub fn explicit_instance_pipe(
127 user_sid_hash: &str,
128 name: &str,
129) -> Result<PipePath, PipePathError> {
130 validate_sid_hash(user_sid_hash)?;
131 validate_service_name(name)?; // same `[a-z0-9-]{1,64}` rule
132 build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-inst-{name}"))
133}
134
135/// Compute the backend pipe address the broker hands a client after
136/// Hello negotiation.
137///
138/// `random128` is a 16-byte (128-bit) random suffix the broker
139/// generates per connection. Rendered as lowercase hex to keep the
140/// pipe name in the `[a-z0-9-]` charset.
141pub fn backend_pipe(
142 user_sid_hash: &str,
143 random128: &[u8; 16],
144) -> Result<PipePath, PipePathError> {
145 validate_sid_hash(user_sid_hash)?;
146 let mut suffix = String::with_capacity(32);
147 for b in random128 {
148 suffix.push(nibble_to_hex(b >> 4));
149 suffix.push(nibble_to_hex(b & 0x0F));
150 }
151 build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-be-{suffix}"))
152}
153
154// ---------------------------------------------------------------------------
155// Validation
156// ---------------------------------------------------------------------------
157
158/// Validate a service name against `[a-z0-9-]{1,64}`.
159///
160/// Exposed for callers that want to validate user input before
161/// computing the pipe name (so they can surface a friendlier error).
162pub fn validate_service_name(name: &str) -> Result<(), PipePathError> {
163 if name.is_empty() {
164 return Err(PipePathError::InvalidName {
165 name: name.into(),
166 reason: "service name must be at least 1 character",
167 });
168 }
169 if name.len() > 64 {
170 return Err(PipePathError::InvalidName {
171 name: name.into(),
172 reason: "service name must be 64 characters or fewer",
173 });
174 }
175 for c in name.chars() {
176 match c {
177 'a'..='z' | '0'..='9' | '-' => {}
178 'A'..='Z' => {
179 // Case-only collision guard — see module docs.
180 return Err(PipePathError::InvalidName {
181 name: name.into(),
182 reason: "uppercase letters are forbidden (case-only \
183 collisions with lowercase names would silently \
184 merge under Windows named-pipe semantics)",
185 });
186 }
187 _ => {
188 return Err(PipePathError::InvalidName {
189 name: name.into(),
190 reason: "only lowercase ASCII letters, digits, and '-' allowed",
191 });
192 }
193 }
194 }
195 Ok(())
196}
197
198/// Validate a semver-like version string against
199/// `^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.]+)?$`.
200///
201/// Used by callers that want to render `{service}-{version}` into a
202/// pipe name themselves (the helpers here keep the name format flat,
203/// but the validator is exposed for the broker-side dispatch table).
204pub fn validate_version(version: &str) -> Result<(), PipePathError> {
205 if version.is_empty() {
206 return Err(PipePathError::InvalidName {
207 name: version.into(),
208 reason: "version must not be empty",
209 });
210 }
211 // Split off pre-release tail.
212 let (core, prerelease) = match version.split_once('-') {
213 Some((core, tail)) => (core, Some(tail)),
214 None => (version, None),
215 };
216 let parts: Vec<&str> = core.split('.').collect();
217 if parts.len() != 3 {
218 return Err(PipePathError::InvalidName {
219 name: version.into(),
220 reason: "version core must be MAJOR.MINOR.PATCH",
221 });
222 }
223 for p in &parts {
224 if p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()) {
225 return Err(PipePathError::InvalidName {
226 name: version.into(),
227 reason: "MAJOR/MINOR/PATCH must be non-empty digits",
228 });
229 }
230 }
231 if let Some(tail) = prerelease {
232 if tail.is_empty() {
233 return Err(PipePathError::InvalidName {
234 name: version.into(),
235 reason: "pre-release suffix after '-' must not be empty",
236 });
237 }
238 for c in tail.chars() {
239 match c {
240 'a'..='z' | '0'..='9' | '.' => {}
241 _ => {
242 return Err(PipePathError::InvalidName {
243 name: version.into(),
244 reason: "pre-release tail allows only [a-z0-9.]",
245 });
246 }
247 }
248 }
249 }
250 Ok(())
251}
252
253fn validate_sid_hash(s: &str) -> Result<(), PipePathError> {
254 if s.len() != 16 {
255 return Err(PipePathError::InvalidName {
256 name: s.into(),
257 reason: "user_sid_hash must be exactly 16 hex characters",
258 });
259 }
260 for c in s.chars() {
261 if !(c.is_ascii_digit() || ('a'..='f').contains(&c)) {
262 return Err(PipePathError::InvalidName {
263 name: s.into(),
264 reason: "user_sid_hash must be lowercase hex",
265 });
266 }
267 }
268 Ok(())
269}
270
271// ---------------------------------------------------------------------------
272// Path assembly
273// ---------------------------------------------------------------------------
274
275#[inline]
276fn nibble_to_hex(n: u8) -> char {
277 match n {
278 0..=9 => (b'0' + n) as char,
279 10..=15 => (b'a' + (n - 10)) as char,
280 _ => unreachable!("nibble out of range"),
281 }
282}
283
284fn build_pipe_path(name: &str) -> Result<PipePath, PipePathError> {
285 #[cfg(windows)]
286 {
287 let path = format!(r"\\.\pipe\{name}");
288 if path.len() > WINDOWS_MAX_PATH {
289 return Err(PipePathError::PathTooLong {
290 len: path.len(),
291 max: WINDOWS_MAX_PATH,
292 limit_label: "Windows MAX_PATH",
293 });
294 }
295 Ok(PipePath {
296 windows: Some(path),
297 unix: None,
298 })
299 }
300
301 #[cfg(unix)]
302 {
303 let dir = unix_broker_socket_dir();
304 // macOS sun_path is only 104 bytes. Per the #228 spec, every
305 // macOS pipe name folds to `{16char-hash}.sock` — there is no
306 // budget to embed the full canonical name. Linux gets 108 bytes
307 // AND a guaranteed $XDG_RUNTIME_DIR (or short /tmp fallback),
308 // so we keep the full name there for debuggability.
309 let leaf = if cfg!(target_os = "macos") {
310 format!("{}.sock", hash_to_16_hex(name.as_bytes()))
311 } else {
312 format!("{name}.sock")
313 };
314 let candidate = dir.join(leaf);
315 let candidate_str = candidate.to_string_lossy();
316 let limit = if cfg!(target_os = "macos") {
317 MACOS_SUN_PATH_MAX
318 } else {
319 LINUX_SUN_PATH_MAX
320 };
321 let limit_label = if cfg!(target_os = "macos") {
322 "macOS sun_path"
323 } else {
324 "Linux sun_path"
325 };
326 // sockaddr_un is NUL-terminated, so the path string itself
327 // must be strictly less than the field width.
328 if candidate_str.len() >= limit {
329 return Err(PipePathError::PathTooLong {
330 len: candidate_str.len(),
331 max: limit - 1,
332 limit_label,
333 });
334 }
335 Ok(PipePath {
336 windows: None,
337 unix: Some(candidate),
338 })
339 }
340}
341
342#[cfg(unix)]
343fn unix_broker_socket_dir() -> PathBuf {
344 // We deliberately do NOT call `client::paths::shadow_dir()` here
345 // because that function has filesystem side effects
346 // (`create_dir_all`). The names module must be pure / no-IO so the
347 // hash + length-limit tests stay deterministic. Callers that need
348 // to actually bind the socket are expected to create the parent
349 // directory themselves.
350 #[cfg(target_os = "macos")]
351 {
352 // Per the #228 spec: macOS has no $XDG_RUNTIME_DIR and a tight
353 // 104-byte sun_path. Use `$TMPDIR/.rp-{uid}` so the parent dir
354 // stays short enough to leave room for the hashed leaf.
355 // `$TMPDIR` on macOS is per-user (e.g. `/var/folders/.../T/`)
356 // so the `-{uid}` suffix is technically redundant there, but
357 // we keep it so the path is well-formed when `$TMPDIR` is
358 // unset (CI containers, restricted launchd contexts) and we
359 // fall back to `/tmp`.
360 let uid = unsafe { libc::getuid() };
361 let tmp = std::env::var_os("TMPDIR")
362 .map(PathBuf::from)
363 .unwrap_or_else(|| PathBuf::from("/tmp"));
364 tmp.join(format!(".rp-{uid}"))
365 }
366 #[cfg(not(target_os = "macos"))]
367 {
368 if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR") {
369 PathBuf::from(d).join("running-process").join("broker")
370 } else {
371 // Fallback: /tmp/running-process-{uid}/broker
372 let uid = unsafe { libc::getuid() };
373 PathBuf::from(format!("/tmp/running-process-{uid}/broker"))
374 }
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 const SAMPLE_HASH: &str = "0123456789abcdef";
383
384 #[test]
385 fn shared_broker_pipe_builds() {
386 let p = shared_broker_pipe(SAMPLE_HASH).expect("shared pipe should build");
387 #[cfg(windows)]
388 {
389 let w = p.windows.expect("windows form populated on Windows");
390 assert!(w.starts_with(r"\\.\pipe\rpb-v1-"));
391 assert!(w.ends_with("-shared"));
392 }
393 #[cfg(all(unix, not(target_os = "macos")))]
394 {
395 let u = p.unix.expect("unix form populated on Unix");
396 let s = u.to_string_lossy();
397 assert!(s.contains("rpb-v1-"));
398 assert!(s.ends_with("-shared.sock"));
399 }
400 #[cfg(target_os = "macos")]
401 {
402 // macOS folds the canonical name into a 16-char hash —
403 // the `rpb-v1-...-shared` segment is the *input* to the
404 // hash but doesn't survive into the path.
405 let u = p.unix.expect("unix form populated on macOS");
406 let s = u.to_string_lossy();
407 assert!(s.ends_with(".sock"));
408 }
409 }
410
411 #[test]
412 fn private_broker_pipe_rejects_uppercase() {
413 let err = private_broker_pipe(SAMPLE_HASH, "Zccache").unwrap_err();
414 match err {
415 PipePathError::InvalidName { .. } => {}
416 _ => panic!("expected InvalidName, got {err:?}"),
417 }
418 }
419
420 #[test]
421 fn validate_version_accepts_semver() {
422 validate_version("1.0.0").unwrap();
423 validate_version("1.11.20").unwrap();
424 validate_version("0.0.1-alpha.1").unwrap();
425 validate_version("2.3.4-rc.1.beta").unwrap();
426 }
427
428 #[test]
429 fn validate_version_rejects_invalid() {
430 assert!(validate_version("").is_err());
431 assert!(validate_version("1.0").is_err());
432 assert!(validate_version("1.0.0.0").is_err());
433 assert!(validate_version("1.0.0-").is_err());
434 assert!(validate_version("1.0.0-ALPHA").is_err()); // uppercase
435 assert!(validate_version("v1.0.0").is_err());
436 }
437
438 #[test]
439 fn backend_pipe_uses_hex_suffix() {
440 let p = backend_pipe(SAMPLE_HASH, &[0xABu8; 16]).expect("backend pipe");
441 let s = match (p.windows, p.unix) {
442 (Some(w), None) => w,
443 (None, Some(u)) => u.to_string_lossy().into_owned(),
444 _ => panic!("exactly one form must be populated"),
445 };
446 // macOS folds the canonical name into a 16-char hash to fit
447 // `sun_path` (104 bytes), so the literal `-be-` segment and
448 // raw hex suffix don't appear on that platform — we still
449 // assert the leaf shape and uniqueness in the integration
450 // test `macos_pipe_paths_are_hashed_leaves`.
451 #[cfg(not(target_os = "macos"))]
452 {
453 assert!(s.contains("-be-"));
454 assert!(s.contains(&"ab".repeat(16)));
455 }
456 #[cfg(target_os = "macos")]
457 {
458 assert!(s.ends_with(".sock"));
459 }
460 }
461}