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(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}