Skip to main content

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}