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(user_sid_hash: &str, service: &str) -> Result<PipePath, PipePathError> {
114 validate_sid_hash(user_sid_hash)?;
115 validate_service_name(service)?;
116 build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-svc-{service}"))
117}
118
119/// Compute the explicit-instance broker pipe address.
120///
121/// `name` must match `[a-z0-9-]{1,64}` and is otherwise unrestricted.
122/// Used for tests and multi-instance dev setups.
123pub fn explicit_instance_pipe(user_sid_hash: &str, name: &str) -> Result<PipePath, PipePathError> {
124 validate_sid_hash(user_sid_hash)?;
125 validate_service_name(name)?; // same `[a-z0-9-]{1,64}` rule
126 build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-inst-{name}"))
127}
128
129/// Compute the backend pipe address the broker hands a client after
130/// Hello negotiation.
131///
132/// `random128` is a 16-byte (128-bit) random suffix the broker
133/// generates per connection. Rendered as lowercase hex to keep the
134/// pipe name in the `[a-z0-9-]` charset.
135pub fn backend_pipe(user_sid_hash: &str, random128: &[u8; 16]) -> Result<PipePath, PipePathError> {
136 validate_sid_hash(user_sid_hash)?;
137 let mut suffix = String::with_capacity(32);
138 for b in random128 {
139 suffix.push(nibble_to_hex(b >> 4));
140 suffix.push(nibble_to_hex(b & 0x0F));
141 }
142 build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-be-{suffix}"))
143}
144
145// ---------------------------------------------------------------------------
146// Validation
147// ---------------------------------------------------------------------------
148
149/// Validate a service name against `[a-z0-9-]{1,64}`.
150///
151/// Exposed for callers that want to validate user input before
152/// computing the pipe name (so they can surface a friendlier error).
153pub fn validate_service_name(name: &str) -> Result<(), PipePathError> {
154 if name.is_empty() {
155 return Err(PipePathError::InvalidName {
156 name: name.into(),
157 reason: "service name must be at least 1 character",
158 });
159 }
160 if name.len() > 64 {
161 return Err(PipePathError::InvalidName {
162 name: name.into(),
163 reason: "service name must be 64 characters or fewer",
164 });
165 }
166 for c in name.chars() {
167 match c {
168 'a'..='z' | '0'..='9' | '-' => {}
169 'A'..='Z' => {
170 // Case-only collision guard — see module docs.
171 return Err(PipePathError::InvalidName {
172 name: name.into(),
173 reason: "uppercase letters are forbidden (case-only \
174 collisions with lowercase names would silently \
175 merge under Windows named-pipe semantics)",
176 });
177 }
178 _ => {
179 return Err(PipePathError::InvalidName {
180 name: name.into(),
181 reason: "only lowercase ASCII letters, digits, and '-' allowed",
182 });
183 }
184 }
185 }
186 Ok(())
187}
188
189/// Validate a semver-like version string against
190/// `^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.]+)?$`.
191///
192/// Used by callers that want to render `{service}-{version}` into a
193/// pipe name themselves (the helpers here keep the name format flat,
194/// but the validator is exposed for the broker-side dispatch table).
195pub fn validate_version(version: &str) -> Result<(), PipePathError> {
196 if version.is_empty() {
197 return Err(PipePathError::InvalidName {
198 name: version.into(),
199 reason: "version must not be empty",
200 });
201 }
202 // Split off pre-release tail.
203 let (core, prerelease) = match version.split_once('-') {
204 Some((core, tail)) => (core, Some(tail)),
205 None => (version, None),
206 };
207 let parts: Vec<&str> = core.split('.').collect();
208 if parts.len() != 3 {
209 return Err(PipePathError::InvalidName {
210 name: version.into(),
211 reason: "version core must be MAJOR.MINOR.PATCH",
212 });
213 }
214 for p in &parts {
215 if p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()) {
216 return Err(PipePathError::InvalidName {
217 name: version.into(),
218 reason: "MAJOR/MINOR/PATCH must be non-empty digits",
219 });
220 }
221 }
222 if let Some(tail) = prerelease {
223 if tail.is_empty() {
224 return Err(PipePathError::InvalidName {
225 name: version.into(),
226 reason: "pre-release suffix after '-' must not be empty",
227 });
228 }
229 for c in tail.chars() {
230 match c {
231 'a'..='z' | '0'..='9' | '.' => {}
232 _ => {
233 return Err(PipePathError::InvalidName {
234 name: version.into(),
235 reason: "pre-release tail allows only [a-z0-9.]",
236 });
237 }
238 }
239 }
240 }
241 Ok(())
242}
243
244fn validate_sid_hash(s: &str) -> Result<(), PipePathError> {
245 if s.len() != 16 {
246 return Err(PipePathError::InvalidName {
247 name: s.into(),
248 reason: "user_sid_hash must be exactly 16 hex characters",
249 });
250 }
251 for c in s.chars() {
252 if !(c.is_ascii_digit() || ('a'..='f').contains(&c)) {
253 return Err(PipePathError::InvalidName {
254 name: s.into(),
255 reason: "user_sid_hash must be lowercase hex",
256 });
257 }
258 }
259 Ok(())
260}
261
262// ---------------------------------------------------------------------------
263// Path assembly
264// ---------------------------------------------------------------------------
265
266#[inline]
267fn nibble_to_hex(n: u8) -> char {
268 match n {
269 0..=9 => (b'0' + n) as char,
270 10..=15 => (b'a' + (n - 10)) as char,
271 _ => unreachable!("nibble out of range"),
272 }
273}
274
275fn build_pipe_path(name: &str) -> Result<PipePath, PipePathError> {
276 #[cfg(windows)]
277 {
278 let path = format!(r"\\.\pipe\{name}");
279 if path.len() > WINDOWS_MAX_PATH {
280 return Err(PipePathError::PathTooLong {
281 len: path.len(),
282 max: WINDOWS_MAX_PATH,
283 limit_label: "Windows MAX_PATH",
284 });
285 }
286 Ok(PipePath {
287 windows: Some(path),
288 unix: None,
289 })
290 }
291
292 #[cfg(unix)]
293 {
294 let dir = unix_broker_socket_dir();
295 // macOS sun_path is only 104 bytes. Per the #228 spec, every
296 // macOS pipe name folds to `{16char-hash}.sock` — there is no
297 // budget to embed the full canonical name. Linux gets 108 bytes
298 // AND a guaranteed $XDG_RUNTIME_DIR (or short /tmp fallback),
299 // so we keep the full name there for debuggability.
300 let leaf = if cfg!(target_os = "macos") {
301 format!("{}.sock", hash_to_16_hex(name.as_bytes()))
302 } else {
303 format!("{name}.sock")
304 };
305 let candidate = dir.join(leaf);
306 let candidate_str = candidate.to_string_lossy();
307 let limit = if cfg!(target_os = "macos") {
308 MACOS_SUN_PATH_MAX
309 } else {
310 LINUX_SUN_PATH_MAX
311 };
312 let limit_label = if cfg!(target_os = "macos") {
313 "macOS sun_path"
314 } else {
315 "Linux sun_path"
316 };
317 // sockaddr_un is NUL-terminated, so the path string itself
318 // must be strictly less than the field width.
319 if candidate_str.len() >= limit {
320 return Err(PipePathError::PathTooLong {
321 len: candidate_str.len(),
322 max: limit - 1,
323 limit_label,
324 });
325 }
326 Ok(PipePath {
327 windows: None,
328 unix: Some(candidate),
329 })
330 }
331}
332
333#[cfg(unix)]
334fn unix_broker_socket_dir() -> PathBuf {
335 // We deliberately do NOT call `client::paths::shadow_dir()` here
336 // because that function has filesystem side effects
337 // (`create_dir_all`). The names module must be pure / no-IO so the
338 // hash + length-limit tests stay deterministic. Callers that need
339 // to actually bind the socket are expected to create the parent
340 // directory themselves.
341 #[cfg(target_os = "macos")]
342 {
343 // Per the #228 spec: macOS has no $XDG_RUNTIME_DIR and a tight
344 // 104-byte sun_path. Use `$TMPDIR/.rp-{uid}` so the parent dir
345 // stays short enough to leave room for the hashed leaf.
346 // `$TMPDIR` on macOS is per-user (e.g. `/var/folders/.../T/`)
347 // so the `-{uid}` suffix is technically redundant there, but
348 // we keep it so the path is well-formed when `$TMPDIR` is
349 // unset (CI containers, restricted launchd contexts) and we
350 // fall back to `/tmp`.
351 let uid = unsafe { libc::getuid() };
352 let tmp = std::env::var_os("TMPDIR")
353 .map(PathBuf::from)
354 .unwrap_or_else(|| PathBuf::from("/tmp"));
355 tmp.join(format!(".rp-{uid}"))
356 }
357 #[cfg(not(target_os = "macos"))]
358 {
359 if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR") {
360 PathBuf::from(d).join("running-process").join("broker")
361 } else {
362 // Fallback: /tmp/running-process-{uid}/broker
363 let uid = unsafe { libc::getuid() };
364 PathBuf::from(format!("/tmp/running-process-{uid}/broker"))
365 }
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 const SAMPLE_HASH: &str = "0123456789abcdef";
374
375 #[test]
376 fn shared_broker_pipe_builds() {
377 let p = shared_broker_pipe(SAMPLE_HASH).expect("shared pipe should build");
378 #[cfg(windows)]
379 {
380 let w = p.windows.expect("windows form populated on Windows");
381 assert!(w.starts_with(r"\\.\pipe\rpb-v1-"));
382 assert!(w.ends_with("-shared"));
383 }
384 #[cfg(all(unix, not(target_os = "macos")))]
385 {
386 let u = p.unix.expect("unix form populated on Unix");
387 let s = u.to_string_lossy();
388 assert!(s.contains("rpb-v1-"));
389 assert!(s.ends_with("-shared.sock"));
390 }
391 #[cfg(target_os = "macos")]
392 {
393 // macOS folds the canonical name into a 16-char hash —
394 // the `rpb-v1-...-shared` segment is the *input* to the
395 // hash but doesn't survive into the path.
396 let u = p.unix.expect("unix form populated on macOS");
397 let s = u.to_string_lossy();
398 assert!(s.ends_with(".sock"));
399 }
400 }
401
402 #[test]
403 fn private_broker_pipe_rejects_uppercase() {
404 let err = private_broker_pipe(SAMPLE_HASH, "Zccache").unwrap_err();
405 match err {
406 PipePathError::InvalidName { .. } => {}
407 _ => panic!("expected InvalidName, got {err:?}"),
408 }
409 }
410
411 #[test]
412 fn validate_version_accepts_semver() {
413 validate_version("1.0.0").unwrap();
414 validate_version("1.11.20").unwrap();
415 validate_version("0.0.1-alpha.1").unwrap();
416 validate_version("2.3.4-rc.1.beta").unwrap();
417 }
418
419 #[test]
420 fn validate_version_rejects_invalid() {
421 assert!(validate_version("").is_err());
422 assert!(validate_version("1.0").is_err());
423 assert!(validate_version("1.0.0.0").is_err());
424 assert!(validate_version("1.0.0-").is_err());
425 assert!(validate_version("1.0.0-ALPHA").is_err()); // uppercase
426 assert!(validate_version("v1.0.0").is_err());
427 }
428
429 #[test]
430 fn backend_pipe_uses_hex_suffix() {
431 let p = backend_pipe(SAMPLE_HASH, &[0xABu8; 16]).expect("backend pipe");
432 let s = match (p.windows, p.unix) {
433 (Some(w), None) => w,
434 (None, Some(u)) => u.to_string_lossy().into_owned(),
435 _ => panic!("exactly one form must be populated"),
436 };
437 // macOS folds the canonical name into a 16-char hash to fit
438 // `sun_path` (104 bytes), so the literal `-be-` segment and
439 // raw hex suffix don't appear on that platform — we still
440 // assert the leaf shape and uniqueness in the integration
441 // test `macos_pipe_paths_are_hashed_leaves`.
442 #[cfg(not(target_os = "macos"))]
443 {
444 assert!(s.contains("-be-"));
445 assert!(s.contains(&"ab".repeat(16)));
446 }
447 #[cfg(target_os = "macos")]
448 {
449 assert!(s.ends_with(".sock"));
450 }
451 }
452}