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// The v1 manifest/service registry and the legacy broker pipe builders use
40// one validation/error type.  `client` composes `daemon-registration`, so
41// this remains the literal same public type on the established broker path.
42pub use crate::daemon_registration::validation::{
43    validate_service_name, validate_version, PipePathError,
44};
45
46/// A pipe address in platform-neutral form.
47///
48/// Exactly one of [`Self::windows`] or [`Self::unix`] is populated on
49/// any given host. The other field is `None`. Which one is populated
50/// follows [`crate::platform::ipc::endpoint_is_filesystem_backed`], so
51/// callers select the active value without naming a host themselves.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct PipePath {
54    /// Windows named-pipe path (e.g. `\\.\pipe\rpb-v1-abc-shared`).
55    pub windows: Option<String>,
56    /// Unix domain socket path (e.g.
57    /// `/run/user/1000/running-process/broker/rpb-v1-abc-shared.sock`).
58    pub unix: Option<PathBuf>,
59}
60
61/// Windows MAX_PATH ceiling without the `\\?\` long-path prefix.
62pub const WINDOWS_MAX_PATH: usize = 260;
63
64/// macOS `sun_path` field ceiling. POSIX requires at least 92;
65/// Darwin's `struct sockaddr_un` actually has 104.
66pub const MACOS_SUN_PATH_MAX: usize = 104;
67
68/// Linux `sun_path` field ceiling. glibc defines it as 108.
69pub const LINUX_SUN_PATH_MAX: usize = 108;
70
71/// Compile-time prefix every broker pipe shares. Encodes the v1
72/// envelope version and the "running-process broker" namespace so
73/// pipe names cannot accidentally collide with anything else under
74/// `\\.\pipe\` or `shadow_dir()/broker/`.
75const PIPE_PREFIX: &str = "rpb-v1";
76
77/// Compute the shared-broker pipe address.
78///
79/// The shared broker is the default: one instance per user that fans
80/// every service request out to the right backend.
81pub fn shared_broker_pipe(user_sid_hash: &str) -> Result<PipePath, PipePathError> {
82    validate_sid_hash(user_sid_hash)?;
83    build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-shared"))
84}
85
86/// Compute the private-broker pipe address for a single service.
87///
88/// Service names must match `[a-z0-9-]{1,64}`.
89pub fn private_broker_pipe(user_sid_hash: &str, service: &str) -> Result<PipePath, PipePathError> {
90    validate_sid_hash(user_sid_hash)?;
91    validate_service_name(service)?;
92    build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-svc-{service}"))
93}
94
95/// Compute the explicit-instance broker pipe address.
96///
97/// `name` must match `[a-z0-9-]{1,64}` and is otherwise unrestricted.
98/// Used for tests and multi-instance dev setups.
99pub fn explicit_instance_pipe(user_sid_hash: &str, name: &str) -> Result<PipePath, PipePathError> {
100    validate_sid_hash(user_sid_hash)?;
101    validate_service_name(name)?; // same `[a-z0-9-]{1,64}` rule
102    build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-inst-{name}"))
103}
104
105/// Compute the backend pipe address the broker hands a client after
106/// Hello negotiation.
107///
108/// `random128` is a 16-byte (128-bit) random suffix the broker
109/// generates per connection. Rendered as lowercase hex to keep the
110/// pipe name in the `[a-z0-9-]` charset.
111pub fn backend_pipe(user_sid_hash: &str, random128: &[u8; 16]) -> Result<PipePath, PipePathError> {
112    validate_sid_hash(user_sid_hash)?;
113    let mut suffix = String::with_capacity(32);
114    for b in random128 {
115        suffix.push(nibble_to_hex(b >> 4));
116        suffix.push(nibble_to_hex(b & 0x0F));
117    }
118    build_pipe_path(&format!("{PIPE_PREFIX}-{user_sid_hash}-be-{suffix}"))
119}
120
121fn validate_sid_hash(s: &str) -> Result<(), PipePathError> {
122    if s.len() != 16 {
123        return Err(PipePathError::InvalidName {
124            name: s.into(),
125            reason: "user_sid_hash must be exactly 16 hex characters",
126        });
127    }
128    for c in s.chars() {
129        if !(c.is_ascii_digit() || ('a'..='f').contains(&c)) {
130            return Err(PipePathError::InvalidName {
131                name: s.into(),
132                reason: "user_sid_hash must be lowercase hex",
133            });
134        }
135    }
136    Ok(())
137}
138
139// ---------------------------------------------------------------------------
140// Path assembly
141// ---------------------------------------------------------------------------
142
143#[inline]
144fn nibble_to_hex(n: u8) -> char {
145    match n {
146        0..=9 => (b'0' + n) as char,
147        10..=15 => (b'a' + (n - 10)) as char,
148        _ => unreachable!("nibble out of range"),
149    }
150}
151
152fn build_pipe_path(name: &str) -> Result<PipePath, PipePathError> {
153    // The selected host owns directory placement, leaf spelling, and the
154    // length budget. This module owns which bare name to ask for.
155    let address = crate::platform::ipc::broker_v1_endpoint_path(name).map_err(|err| {
156        PipePathError::PathTooLong {
157            len: err.len,
158            max: err.max,
159            limit_label: err.limit_label,
160        }
161    })?;
162
163    Ok(if crate::platform::ipc::endpoint_is_filesystem_backed() {
164        PipePath {
165            windows: None,
166            unix: Some(PathBuf::from(address)),
167        }
168    } else {
169        PipePath {
170            windows: Some(address),
171            unix: None,
172        }
173    })
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    const SAMPLE_HASH: &str = "0123456789abcdef";
181
182    /// Return the single populated form, asserting the other is empty.
183    ///
184    /// Host-specific spelling of that form is characterized beside each
185    /// concrete implementation in `running-process-platform-internal`;
186    /// this module owns only the host-neutral contract.
187    fn sole_address(path: &PipePath) -> String {
188        match (&path.windows, &path.unix) {
189            (Some(windows), None) => windows.clone(),
190            (None, Some(unix)) => unix.to_string_lossy().into_owned(),
191            _ => panic!("exactly one form must be populated"),
192        }
193    }
194
195    #[test]
196    fn shared_broker_pipe_builds() {
197        let path = shared_broker_pipe(SAMPLE_HASH).expect("shared pipe should build");
198        assert!(!sole_address(&path).is_empty());
199    }
200
201    #[test]
202    fn populated_form_follows_the_selected_transport() {
203        let path = shared_broker_pipe(SAMPLE_HASH).expect("shared pipe should build");
204        assert_eq!(
205            path.unix.is_some(),
206            crate::platform::ipc::endpoint_is_filesystem_backed(),
207            "filesystem-backed hosts must populate the unix form and no other"
208        );
209    }
210
211    #[test]
212    fn the_derived_address_respects_the_host_budget() {
213        let limit = crate::platform::ipc::endpoint_name_limit();
214        let path = backend_pipe(SAMPLE_HASH, &[0xABu8; 16]).expect("backend pipe");
215        assert!(
216            sole_address(&path).len() <= limit.max_bytes,
217            "derived address exceeds the {} budget of {} bytes",
218            limit.label,
219            limit.max_bytes
220        );
221    }
222
223    #[test]
224    fn the_host_budget_is_one_of_the_documented_ceilings() {
225        let limit = crate::platform::ipc::endpoint_name_limit();
226        assert!(
227            matches!(
228                (limit.max_bytes, limit.label),
229                (WINDOWS_MAX_PATH, "Windows MAX_PATH")
230                    | (MACOS_SUN_PATH_MAX, "macOS sun_path")
231                    | (LINUX_SUN_PATH_MAX, "Linux sun_path")
232            ),
233            "facade reported {} / {} bytes, which matches no documented ceiling",
234            limit.label,
235            limit.max_bytes
236        );
237    }
238
239    #[test]
240    fn private_broker_pipe_rejects_uppercase() {
241        let err = private_broker_pipe(SAMPLE_HASH, "Zccache").unwrap_err();
242        match err {
243            PipePathError::InvalidName { .. } => {}
244            _ => panic!("expected InvalidName, got {err:?}"),
245        }
246    }
247
248    #[test]
249    fn validate_version_accepts_semver() {
250        validate_version("1.0.0").unwrap();
251        validate_version("1.11.20").unwrap();
252        validate_version("0.0.1-alpha.1").unwrap();
253        validate_version("2.3.4-rc.1.beta").unwrap();
254    }
255
256    #[test]
257    fn validate_version_rejects_invalid() {
258        assert!(validate_version("").is_err());
259        assert!(validate_version("1.0").is_err());
260        assert!(validate_version("1.0.0.0").is_err());
261        assert!(validate_version("1.0.0-").is_err());
262        assert!(validate_version("1.0.0-ALPHA").is_err()); // uppercase
263        assert!(validate_version("v1.0.0").is_err());
264    }
265
266    #[test]
267    fn backend_pipes_are_distinct_per_random_suffix() {
268        // macOS folds the canonical name into a hashed leaf, so the raw hex
269        // suffix is not observable in the address on every host. Uniqueness
270        // is the property every host must preserve.
271        let first = backend_pipe(SAMPLE_HASH, &[0xABu8; 16]).expect("backend pipe");
272        let second = backend_pipe(SAMPLE_HASH, &[0xCDu8; 16]).expect("backend pipe");
273        assert_ne!(sole_address(&first), sole_address(&second));
274    }
275}