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}