running_process/broker/lifecycle/names_v2.rs
1//! v2 broker pipe-name derivation (slice 3b of #483).
2//!
3//! Mirrors [`super::names`] for the v2 broker, using a different
4//! namespace prefix (`rpb-v2-` instead of `rpb-v1-`) so a v1 and v2
5//! broker for the same program can coexist on one machine. Per #470's
6//! coexistence table, v2 ships as a parallel stack alongside v1 during
7//! rollout; matching the v1 module's API shape lets later slices and
8//! downstream consumers (zccache, etc.) port surfaces one at a time
9//! without learning a new naming model.
10//!
11//! Slice 3b exposes only [`v2_program_pipe`] — the per-program pipe
12//! name a v2 broker binds and a v2 client dials. Subsequent slices add
13//! more names (private/shared/explicit-instance counterparts, the
14//! broker↔daemon transport name) as they are needed.
15
16use std::path::{Path, PathBuf};
17
18use crate::broker::lifecycle::names::{validate_service_name, PipePathError};
19
20/// Compile-time prefix for every v2 broker pipe. Counterpart of the
21/// frozen v1 `PIPE_PREFIX = "rpb-v1"`. Encodes the v2 envelope version
22/// so v1 and v2 brokers can bind simultaneously without colliding.
23const PIPE_PREFIX_V2: &str = "rpb-v2";
24
25/// Failure to turn an installed broker executable path into an IPC scope.
26#[derive(Debug, thiserror::Error)]
27pub enum BrokerPathIdentityError {
28 /// The executable path must exist so every process hashes the same
29 /// canonical filesystem identity. There is deliberately no lexical-path
30 /// fallback: disagreement here would send clients to a different pipe.
31 #[error("canonicalize installed broker path {path:?}: {source}")]
32 Canonicalize {
33 /// Broker executable path supplied by the caller.
34 path: PathBuf,
35 /// Filesystem error returned by canonicalization.
36 #[source]
37 source: std::io::Error,
38 },
39}
40
41/// Derive the 16-hex IPC scope from the canonical installed broker path.
42///
43/// The path itself is the scope contract. A per-user installation naturally
44/// contains that user's private install path, while every caller of a
45/// machine-wide installation sees the same path and therefore the same pipe.
46/// No SID, machine-id, registry mapping, or fallback namespace participates.
47pub fn broker_path_scope_hash(
48 broker_path: impl AsRef<Path>,
49) -> Result<String, BrokerPathIdentityError> {
50 let supplied = broker_path.as_ref();
51 let canonical = std::fs::canonicalize(supplied).map_err(|source| {
52 BrokerPathIdentityError::Canonicalize {
53 path: supplied.to_path_buf(),
54 source,
55 }
56 })?;
57
58 let mut hasher = blake3::Hasher::new();
59 hasher.update(b"running-process:broker-install-path:v1\0");
60 // The host decides which spelling differences are meaningless; this
61 // module owns the domain separator and the encoding below.
62 hasher.update(&crate::platform::ipc::endpoint_scope_bytes(&canonical));
63
64 let digest = hasher.finalize();
65 let mut scope = String::with_capacity(16);
66 for byte in digest.as_bytes().iter().take(8) {
67 use std::fmt::Write as _;
68 let _ = write!(scope, "{byte:02x}");
69 }
70 Ok(scope)
71}
72
73/// Compute the v2 pipe name for one installed broker executable.
74///
75/// This is the path-scoped counterpart of [`v2_program_pipe`]. Both broker
76/// and client should call it with the same canonical installed executable;
77/// the resulting endpoint contains no per-user SID component.
78pub fn v2_broker_path_pipe(
79 program: &str,
80 broker_path: impl AsRef<Path>,
81 pipe_idx: u32,
82) -> Result<String, BrokerPathPipeError> {
83 let scope = broker_path_scope_hash(broker_path)?;
84 Ok(v2_program_pipe(program, &scope, pipe_idx)?)
85}
86
87/// Error returned while deriving a path-scoped v2 pipe name.
88#[derive(Debug, thiserror::Error)]
89pub enum BrokerPathPipeError {
90 /// Installed broker path identity could not be resolved exactly.
91 #[error(transparent)]
92 Identity(#[from] BrokerPathIdentityError),
93 /// The program/scope combination was not a valid v2 pipe name.
94 #[error(transparent)]
95 Pipe(#[from] PipePathError),
96}
97
98/// Compute the v2 per-program pipe name.
99///
100/// Returns `"rpb-v2-{program}-{sid_hash}-{pipe_idx}"` after validating
101/// `program` against the same `[a-z0-9-]{1,64}` rule as v1 service
102/// names (case-only collisions are rejected for the same Windows
103/// named-pipe reason documented on v1's [`validate_service_name`]) and
104/// `sid_hash` for non-emptiness + 16-char hex shape.
105///
106/// `pipe_idx` is included so a v2 broker can bind multiple acceptor
107/// pipes (`-0`, `-1`, ...) for fanout, mirroring the v1 pattern
108/// `rpb-v1-<program>-<sid_hash>-<pipe_idx>` documented in #470.
109///
110/// This slice returns just the canonical name string. Wrapping that
111/// into a platform-neutral `PipePath` (Windows `\\.\pipe\…` vs Unix
112/// socket file under the broker shadow dir) lands in slice 3c when the
113/// v2 binary actually starts binding.
114pub fn v2_program_pipe(
115 program: &str,
116 sid_hash: &str,
117 pipe_idx: u32,
118) -> Result<String, PipePathError> {
119 validate_service_name(program)?;
120 validate_sid_hash(sid_hash)?;
121 Ok(format!("{PIPE_PREFIX_V2}-{program}-{sid_hash}-{pipe_idx}"))
122}
123
124/// Validate that `sid_hash` is exactly 16 lowercase hex characters —
125/// the same shape produced by [`super::sid::user_sid_hash`] /
126/// [`super::sid::hash_to_16_hex`].
127fn validate_sid_hash(sid_hash: &str) -> Result<(), PipePathError> {
128 if sid_hash.is_empty() {
129 return Err(PipePathError::InvalidName {
130 name: sid_hash.into(),
131 reason: "sid_hash must be at least 1 character",
132 });
133 }
134 if sid_hash.len() != 16 {
135 return Err(PipePathError::InvalidName {
136 name: sid_hash.into(),
137 reason: "sid_hash must be exactly 16 hex characters",
138 });
139 }
140 for c in sid_hash.chars() {
141 if !c.is_ascii_hexdigit() || c.is_ascii_uppercase() {
142 return Err(PipePathError::InvalidName {
143 name: sid_hash.into(),
144 reason: "sid_hash must be lowercase hex digits",
145 });
146 }
147 }
148 Ok(())
149}
150
151/// Directory holding a v2 broker's per-user runtime state.
152///
153/// # Why this exists as one function
154///
155/// On Unix the broker's socket already lives in a directory, so runtime
156/// state had an implicit home. On Windows the socket is a named pipe in a
157/// kernel namespace with no directory at all — so anything that must be a
158/// *file* (the HTTP endpoint published by [`super::super::broker_http_discovery`],
159/// for one) had nowhere agreed to live.
160///
161/// A publisher and a reader that each derive that location independently
162/// will eventually disagree, and the failure is silent: the reader simply
163/// reports "no broker running" forever. Both sides call this instead.
164///
165/// The directory is not created here. Callers that write into it create it
166/// owner-only at that point; callers that only read must treat an absent
167/// directory as "nothing published", which is a normal state.
168///
169/// # Why no `getuid()`
170///
171/// The obvious way to keep two users on one host apart is a uid in the path.
172/// Every branch instead lands inside a location the OS already scopes to one
173/// user -- `XDG_RUNTIME_DIR`, macOS's per-user `TMPDIR`, `LOCALAPPDATA`, or
174/// the per-user cache directory. Separation comes from the base directory
175/// rather than from a uid spelled into the leaf, and the file itself is
176/// written owner-only by `broker_http_discovery::publish_http_port`.
177///
178/// That choice originally also kept `libc::getuid`, an `unsafe` call, out of
179/// `crates/running-process/src/broker/` and its reviewed unsafe inventory
180/// (`tests/security/unsafe_inventory.rs`). The placement now lives behind
181/// `platform::ipc`, so that particular pressure no longer applies -- but the
182/// design is retained deliberately: an OS-scoped base directory needs no
183/// privilege to read and cannot be spoofed by a caller supplying a uid.
184///
185/// A consequence worth stating: this is *not* guaranteed to be the same
186/// directory the Unix socket lives in. It is the agreed home for broker-v2
187/// runtime *files*, which is all the publisher and reader need to share.
188pub fn broker_v2_runtime_dir() -> std::path::PathBuf {
189 crate::platform::ipc::broker_v2_runtime_dir()
190}
191
192/// Path of the identity file a daemon publishes for `service`.
193///
194/// # Why the service name is the key
195///
196/// The broker resolves a Hello by `service_name` and knows nothing else about
197/// the daemon behind it. The daemon, in turn, is parameterised by *scope* and
198/// has no inherent notion of which service it serves. Those two facts left no
199/// shared identifier between them, which is what blocked backend-pipe
200/// resolution (running-process#532 item 5) — not the choice of directory.
201///
202/// So the service name is supplied to the daemon explicitly (`--service`) and
203/// used as the key here. Both sides call this function rather than building
204/// the path themselves: a publisher and a reader that each derive it
205/// independently will eventually disagree, and the failure is silent — the
206/// broker simply reports the daemon as absent forever.
207///
208/// The file is not created here, and a missing file is a normal state
209/// meaning "no daemon has published for this service".
210pub fn daemon_identity_path(service: &str) -> std::path::PathBuf {
211 broker_v2_runtime_dir().join(format!("daemon-{service}.json"))
212}
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 /// A relative path would resolve against the current directory, so a
218 /// broker and a client started from different working directories would
219 /// silently use different files.
220 #[test]
221 fn the_runtime_dir_is_absolute() {
222 let dir = broker_v2_runtime_dir();
223 assert!(dir.is_absolute(), "{} is not absolute", dir.display());
224 }
225
226 /// The publisher and the reader are separate calls. If this were not
227 /// stable, one would write where the other never looks.
228 #[test]
229 fn the_runtime_dir_is_stable_across_calls() {
230 assert_eq!(broker_v2_runtime_dir(), broker_v2_runtime_dir());
231 }
232
233 /// Must not collide with v1 or the daemon's state: two brokers sharing a
234 /// directory would each overwrite the other's published endpoint.
235 #[test]
236 fn the_runtime_dir_is_specific_to_broker_v2() {
237 let dir = broker_v2_runtime_dir();
238 let text = dir.to_string_lossy();
239 assert!(
240 text.contains("broker-v2"),
241 "{text} does not name broker-v2, so it may be shared with other components"
242 );
243 }
244
245 const VALID_SID: &str = "deadbeefcafef00d";
246
247 /// Publisher and reader must land on the same file. They call this from
248 /// different processes, so a difference here is invisible until the
249 /// broker reports a running daemon as absent.
250 #[test]
251 fn the_identity_path_is_stable_and_service_specific() {
252 let a = daemon_identity_path("zccache");
253 assert_eq!(a, daemon_identity_path("zccache"));
254 assert_ne!(a, daemon_identity_path("fbuild"));
255 assert!(a.is_absolute(), "{} is not absolute", a.display());
256 assert!(a.starts_with(broker_v2_runtime_dir()));
257 assert!(
258 a.to_string_lossy().contains("zccache"),
259 "{} does not name the service",
260 a.display()
261 );
262 }
263
264 #[test]
265 fn v2_program_pipe_happy_path() {
266 let name =
267 v2_program_pipe("zccache", VALID_SID, 0).expect("valid inputs produce a v2 pipe name");
268 assert_eq!(name, "rpb-v2-zccache-deadbeefcafef00d-0");
269 }
270
271 #[test]
272 fn v2_program_pipe_distinct_pipe_idx_distinct_names() {
273 let name_0 = v2_program_pipe("zccache", VALID_SID, 0).expect("idx=0 valid");
274 let name_7 = v2_program_pipe("zccache", VALID_SID, 7).expect("idx=7 valid");
275 assert_ne!(name_0, name_7);
276 assert!(name_7.ends_with("-7"));
277 }
278
279 #[test]
280 fn v2_program_pipe_rejects_invalid_program() {
281 // Empty program name.
282 assert!(matches!(
283 v2_program_pipe("", VALID_SID, 0),
284 Err(PipePathError::InvalidName { .. })
285 ));
286 // Uppercase (case-only collision risk on Windows).
287 assert!(matches!(
288 v2_program_pipe("Zccache", VALID_SID, 0),
289 Err(PipePathError::InvalidName { .. })
290 ));
291 // 65 characters (over the v1-derived length cap).
292 let too_long = "a".repeat(65);
293 assert!(matches!(
294 v2_program_pipe(&too_long, VALID_SID, 0),
295 Err(PipePathError::InvalidName { .. })
296 ));
297 }
298
299 #[test]
300 fn v2_program_pipe_rejects_invalid_sid_hash() {
301 // Empty sid_hash.
302 assert!(matches!(
303 v2_program_pipe("zccache", "", 0),
304 Err(PipePathError::InvalidName { .. })
305 ));
306 // Wrong length (15 chars).
307 assert!(matches!(
308 v2_program_pipe("zccache", "deadbeefcafef00", 0),
309 Err(PipePathError::InvalidName { .. })
310 ));
311 // Non-hex character.
312 assert!(matches!(
313 v2_program_pipe("zccache", "deadbeefcafef00g", 0),
314 Err(PipePathError::InvalidName { .. })
315 ));
316 // Uppercase hex (not the canonical shape).
317 assert!(matches!(
318 v2_program_pipe("zccache", "DEADBEEFCAFEF00D", 0),
319 Err(PipePathError::InvalidName { .. })
320 ));
321 }
322
323 #[test]
324 fn broker_path_scope_is_stable_and_path_specific() {
325 let temp = tempfile::tempdir().expect("tempdir");
326 let first = temp.path().join("broker-a");
327 let second = temp.path().join("broker-b");
328 std::fs::write(&first, b"a").expect("first broker fixture");
329 std::fs::write(&second, b"b").expect("second broker fixture");
330
331 let a = broker_path_scope_hash(&first).expect("first scope");
332 assert_eq!(a, broker_path_scope_hash(&first).expect("stable scope"));
333 assert_ne!(a, broker_path_scope_hash(&second).expect("second scope"));
334 assert_eq!(a.len(), 16);
335 assert!(a
336 .chars()
337 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
338 }
339
340 #[test]
341 fn path_scoped_pipe_contains_no_user_identity() {
342 let temp = tempfile::tempdir().expect("tempdir");
343 let broker = temp.path().join("soldr");
344 std::fs::write(&broker, b"broker").expect("broker fixture");
345 let scope = broker_path_scope_hash(&broker).expect("scope");
346
347 let name = v2_broker_path_pipe("soldr-daemon", &broker, 1).expect("pipe");
348 assert_eq!(name, format!("rpb-v2-soldr-daemon-{scope}-1"));
349 }
350
351 #[test]
352 fn missing_broker_path_has_no_lexical_fallback() {
353 let missing = std::env::temp_dir().join(format!(
354 "running-process-missing-broker-{}",
355 std::process::id()
356 ));
357 let err = broker_path_scope_hash(&missing).expect_err("missing path must fail");
358 assert!(matches!(err, BrokerPathIdentityError::Canonicalize { .. }));
359 }
360}