nros/env.rs
1//! The hosted edge of configuration: the process environment.
2//!
3//! issue 0687 / phase-359 W10. Every env var nano-ros honours is read HERE and
4//! nowhere else. The core (`nros-node`) takes values — `ExecutorConfig::
5//! resolve_with` accepts an [`EnvRung`](nros_node::EnvRung) of already-resolved fields — so it
6//! needs no `std`, no `env` capability, and no stub on the five ports that have
7//! no environment to read.
8//!
9//! That is the difference between this and the ABI ports W10 made for the
10//! clock, sleep, tasks and the log sink: those name capabilities every RTOS
11//! HAS. A process environment is not one. Modelling it as an ABI entry would
12//! have put `return 0` in five ports and permanent surface in the header, for a
13//! facility that exists on one platform family.
14//!
15//! **One reader, through the C library** (phase-359 W10 stage B). The
16//! environment is read with `getenv`, not `std::env`. A process environment is
17//! a property of the PROCESS, not of the Rust standard library: threadx-linux
18//! and freertos-posix are `#![no_std]` images that are still Linux processes
19//! with a C library, and issue 1763 measured what happens when they stop
20//! honouring `$ROS_DOMAIN_ID` (parallel cells shared a domain and heard each
21//! other). There used to be two readers — this one on `std::env` and
22//! ~~`host_env`~~ on `getenv` — with the same variables and rules; they are one
23//! now, and `nros` needs no `std` to be a hosted edge. The edge is "a C library
24//! is linked", which on this tree is `target_os = "linux"`; a cross target has
25//! no `getenv` and this module does not exist there.
26//!
27//! Strings are borrowed from the process environment, which outlives the boot,
28//! so the resolved config's `&'static str` fields point straight into it. No
29//! cache is needed (the old `std::env` reader kept a `OnceLock` only to turn an
30//! owned `String` into a `&'static str`, and its staleness was issue 0607).
31//! Nothing in nano-ros calls `setenv` after boot.
32//!
33//! What lives here:
34//!
35//! * [`ExecutorConfigEnvExt::from_env`] — the constructor that used to be an
36//! inherent method on `ExecutorConfig`. Bring the trait into scope (the
37//! prelude has it) and the call site spelling is unchanged. `env` feature.
38//! * [`resolve_hosted`](crate::env::resolve_hosted) / [`try_resolve_hosted`](crate::env::try_resolve_hosted) — RFC-0045 precedence model A
39//! with the environment rung on top. The C / C++ entries call these on every
40//! Linux build, `env` or not.
41//! * [`rmw_selector`] — the one `$NROS_RMW` reader (issue 0687's first half).
42//! * [`var`](crate::env::var) / [`read_file`](crate::env::read_file) — the two C-library reads the hosted `init*`
43//! entries need (`$RMW_IMPLEMENTATION`, `--params-file`).
44//! * [`write_file`](crate::env::write_file), [`rename`](crate::env::rename), [`set_var`](crate::env::set_var), [`exe_name`](crate::env::exe_name), [`thread_key`](crate::env::thread_key),
45//! [`exit`](crate::env::exit) — the rest of the hosted PROCESS edge, for the C / C++ surfaces
46//! (phase-359 W10 stage C). `nros-c` and `nros-cpp` write their census,
47//! metadata and profile files through these instead of `std::fs` /
48//! `std::process`, so the image root never links libstd: a census is a
49//! property of a process with a C library, not of the Rust standard library.
50//! One spelling of each C-library call, here, rather than one per crate.
51
52use core::ffi::{CStr, c_char};
53
54use crate::retired_env::RETIRED_ENV_VARS;
55use nros_node::{BootConfig, BootConfigError, EnvRung, ExecutorConfig, RMW_SELECTOR_CAP};
56use nros_rmw::SessionMode;
57
58unsafe extern "C" {
59 fn getenv(name: *const c_char) -> *const c_char;
60}
61
62/// `$NAME` as a C string, or `None` when unset — the one `getenv` call.
63fn raw(name: &CStr) -> Option<&'static CStr> {
64 // SAFETY: `name` is NUL-terminated; `getenv` returns NULL or a pointer to
65 // a NUL-terminated string in the process environment, which lives for the
66 // process, so a non-NULL result is a valid `'static` C string.
67 unsafe {
68 let p = getenv(name.as_ptr());
69 (!p.is_null()).then(|| CStr::from_ptr(p))
70 }
71}
72
73/// `$NAME` as UTF-8, or `None` when unset or not UTF-8 (`std::env::var`'s two
74/// `Err` cases, which the old std reader also treated as unset).
75///
76/// `name` is a Rust string; one longer than 63 bytes, or one holding a NUL,
77/// is no variable this tree reads and answers `None`.
78pub fn var(name: &str) -> Option<&'static str> {
79 let mut buf = [0u8; 64];
80 if name.len() >= buf.len() || name.as_bytes().contains(&0) {
81 return None;
82 }
83 buf[..name.len()].copy_from_slice(name.as_bytes());
84 let cname = CStr::from_bytes_until_nul(&buf).ok()?;
85 raw(cname)?.to_str().ok()
86}
87
88fn non_empty(v: Option<&'static str>) -> Option<&'static str> {
89 v.filter(|s| !s.is_empty())
90}
91
92/// `path` as a NUL-terminated buffer for the C library, or `None` when it holds
93/// a NUL byte (no file is named by such a path).
94#[cfg(feature = "alloc")]
95fn c_path(path: &str) -> Option<alloc::vec::Vec<u8>> {
96 if path.as_bytes().contains(&0) {
97 return None;
98 }
99 let mut cpath = alloc::vec::Vec::with_capacity(path.len() + 1);
100 cpath.extend_from_slice(path.as_bytes());
101 cpath.push(0);
102 Some(cpath)
103}
104
105/// A whole file, through the C library — `std::fs::read_to_string` for a
106/// crate with no `std`. `--params-file` reads through it, and so do the
107/// C / C++ surfaces' own params-file entries.
108///
109/// The error is a short reason (`"cannot be opened"`, `"is not UTF-8"`, ...),
110/// for the caller to put beside the path it already holds.
111#[cfg(feature = "alloc")]
112pub fn read_file(path: &str) -> Result<alloc::string::String, &'static str> {
113 use core::ffi::c_void;
114 unsafe extern "C" {
115 fn fopen(path: *const c_char, mode: *const c_char) -> *mut c_void;
116 fn fread(ptr: *mut c_void, size: usize, n: usize, f: *mut c_void) -> usize;
117 fn ferror(f: *mut c_void) -> i32;
118 fn fclose(f: *mut c_void) -> i32;
119 }
120 let Some(cpath) = c_path(path) else {
121 return Err("names a path holding a NUL byte");
122 };
123 // SAFETY: both strings are NUL-terminated; `f` is checked before use and
124 // closed exactly once; `fread` writes at most `chunk.len()` bytes.
125 let bytes = unsafe {
126 let f = fopen(cpath.as_ptr().cast(), c"rb".as_ptr());
127 if f.is_null() {
128 return Err("cannot be opened");
129 }
130 let mut out = alloc::vec::Vec::new();
131 let mut chunk = [0u8; 4096];
132 loop {
133 let n = fread(chunk.as_mut_ptr().cast(), 1, chunk.len(), f);
134 out.extend_from_slice(&chunk[..n]);
135 if n < chunk.len() {
136 break;
137 }
138 }
139 let failed = ferror(f) != 0;
140 fclose(f);
141 if failed {
142 return Err("cannot be read");
143 }
144 out
145 };
146 alloc::string::String::from_utf8(bytes).map_err(|_| "is not UTF-8")
147}
148
149/// Write `bytes` to `path`, creating or truncating it — `std::fs::write` for a
150/// crate with no `std`. The error is a short reason, as for [`read_file`].
151#[cfg(feature = "alloc")]
152pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), &'static str> {
153 use core::ffi::c_void;
154 unsafe extern "C" {
155 fn fopen(path: *const c_char, mode: *const c_char) -> *mut c_void;
156 fn fwrite(ptr: *const c_void, size: usize, n: usize, f: *mut c_void) -> usize;
157 fn fclose(f: *mut c_void) -> i32;
158 }
159 let Some(cpath) = c_path(path) else {
160 return Err("names a path holding a NUL byte");
161 };
162 // SAFETY: both strings are NUL-terminated; `f` is checked before use and
163 // closed exactly once; `fwrite` reads at most `bytes.len()` bytes.
164 unsafe {
165 let f = fopen(cpath.as_ptr().cast(), c"wb".as_ptr());
166 if f.is_null() {
167 return Err("cannot be opened for writing");
168 }
169 let written = fwrite(bytes.as_ptr().cast(), 1, bytes.len(), f);
170 // `fclose` flushes: a full disk shows up here, not in `fwrite`.
171 let closed = fclose(f) == 0;
172 if written != bytes.len() || !closed {
173 return Err("cannot be written");
174 }
175 }
176 Ok(())
177}
178
179/// Rename `from` to `to`, replacing `to` — `std::fs::rename`. Same filesystem
180/// only, which is how its callers use it (write-then-rename beside the target).
181#[cfg(feature = "alloc")]
182pub fn rename(from: &str, to: &str) -> Result<(), &'static str> {
183 unsafe extern "C" {
184 fn rename(from: *const c_char, to: *const c_char) -> i32;
185 }
186 let (Some(f), Some(t)) = (c_path(from), c_path(to)) else {
187 return Err("names a path holding a NUL byte");
188 };
189 // SAFETY: both strings are NUL-terminated and live for the call.
190 match unsafe { rename(f.as_ptr().cast(), t.as_ptr().cast()) } {
191 0 => Ok(()),
192 _ => Err("cannot be renamed"),
193 }
194}
195
196/// Set `$name` to `value` for this process — `std::env::set_var`.
197///
198/// # Safety
199///
200/// The process must be single-threaded, or no other thread may read or write
201/// the environment during the call: `setenv` is not thread-safe, which is why
202/// `std::env::set_var` is `unsafe` too. Its callers run in the boot funnel,
203/// before any executor or tier task exists.
204#[cfg(feature = "alloc")]
205pub unsafe fn set_var(name: &str, value: &str) {
206 unsafe extern "C" {
207 fn setenv(name: *const c_char, value: *const c_char, overwrite: i32) -> i32;
208 }
209 let (Some(n), Some(v)) = (c_path(name), c_path(value)) else {
210 return;
211 };
212 // SAFETY: both strings are NUL-terminated; the caller upholds the
213 // single-threaded condition.
214 unsafe { setenv(n.as_ptr().cast(), v.as_ptr().cast(), 1) };
215}
216
217/// The file name of this process's executable (`/proc/self/exe`'s last
218/// component) — what `std::env::current_exe()` + `file_name()` gave. `None`
219/// when the link cannot be read.
220#[cfg(feature = "alloc")]
221pub fn exe_name() -> Option<alloc::string::String> {
222 unsafe extern "C" {
223 fn readlink(path: *const c_char, buf: *mut c_char, len: usize) -> isize;
224 }
225 let mut buf = [0u8; 4096];
226 // SAFETY: the path is NUL-terminated; `readlink` writes at most
227 // `buf.len()` bytes and returns how many (it does not NUL-terminate).
228 let n = unsafe {
229 readlink(
230 c"/proc/self/exe".as_ptr(),
231 buf.as_mut_ptr().cast(),
232 buf.len(),
233 )
234 };
235 let full = core::str::from_utf8(buf.get(..usize::try_from(n).ok()?)?).ok()?;
236 Some(alloc::string::String::from(
237 full.rsplit('/').next().unwrap_or(full),
238 ))
239}
240
241/// A key unique to the calling thread among live threads — the address a
242/// `thread_local!` would give, without `std`. `pthread_t` on Linux is the
243/// thread's descriptor address.
244pub fn thread_key() -> usize {
245 unsafe extern "C" {
246 fn pthread_self() -> usize;
247 }
248 // SAFETY: `pthread_self` has no preconditions and cannot fail.
249 unsafe { pthread_self() }
250}
251
252/// End the process with `code`, running `atexit` handlers and flushing stdio —
253/// `std::process::exit`.
254pub fn exit(code: i32) -> ! {
255 unsafe extern "C" {
256 fn exit(code: i32) -> !;
257 }
258 // SAFETY: `exit` has no preconditions; it does not return.
259 unsafe { exit(code) }
260}
261
262/// **The** answer to "which RMW backend did the user select" — one reader, one
263/// semantic, for every consumer.
264///
265/// phase-359 W10 / issue 0687. This variable had FOUR readers with THREE
266/// semantics: `Executor::open` read it as raw OS bytes, `nros`'s
267/// `open_session` as a UTF-8 string filtered for empty, `nros-c`'s entry as a
268/// string passed through EVEN WHEN EMPTY, and `nros::init` as a string with an
269/// `RMW_IMPLEMENTATION` fallback the other three did not have. "Which backend
270/// did the user ask for" had four answers in one process.
271///
272/// Two decisions are baked in, and both are deliberate:
273///
274/// * **`$NROS_RMW` only.** `$RMW_IMPLEMENTATION` is NOT folded in, though it
275/// was tempting and one reader did it. The two carry different vocabularies:
276/// this selector is matched against the cffi registry's canonical names
277/// (`zenoh`, `dds`, `cyclonedds`), while `RMW_IMPLEMENTATION` holds ROS names
278/// (`rmw_cyclonedds_cpp`). Feeding a ROS name to `resolve_backend` yields
279/// `Unknown` — an ERROR — where today it is ignored and the unique-backend
280/// path runs. Unifying them without a mapping would convert "ignored" into
281/// "fails to start". The hosted `init` keeps its fallback for the
282/// `Context.rmw` HINT, which is a different quantity.
283/// * **Empty or non-UTF-8 means unset.** A name that is not UTF-8 cannot match
284/// a registry entry, so treating it as absent is what the caller wants.
285///
286/// The return is `heapless::String` rather than `&str` because
287/// `RMW_SELECTOR_CAP` is a real bound, not a guess: it is the capacity of
288/// `Executor::primary_rmw_name`, so a longer value cannot name a registry slot
289/// and is reported as unset rather than truncated into a different backend's
290/// name.
291pub fn rmw_selector() -> Option<nros_core::heapless::String<RMW_SELECTOR_CAP>> {
292 let s = non_empty(var("NROS_RMW"))?;
293 nros_core::heapless::String::try_from(s).ok()
294}
295
296/// The environment as an [`EnvRung`], for [`ExecutorConfig::resolve_with`].
297fn env_rung() -> Result<EnvRung<'static>, BootConfigError> {
298 // phase-484 W8c — a retired alias is REFUSED, never ignored: an ignored
299 // `ZENOH_LOCATOR` dials the default router and reads as "no delivery".
300 // Presence alone refuses (an empty value is still somebody's intent), and
301 // the refusal is logged here so the C / C++ shims, which only get a
302 // return code, still name the replacement. Only presence is tested, so a
303 // non-UTF-8 value still refuses.
304 for &(name, replacement) in RETIRED_ENV_VARS {
305 let mut cname = [0u8; 32];
306 cname[..name.len()].copy_from_slice(name.as_bytes());
307 let cname = CStr::from_bytes_until_nul(&cname).expect("retired env name fits");
308 if raw(cname).is_some() {
309 let err = BootConfigError::RetiredEnvVar { name, replacement };
310 nros_log::log_error!(nros_log::get_logger("nros"), "{}", err);
311 return Err(err);
312 }
313 }
314 // Issue 0330 — a SET locator is the env's answer even when empty; the
315 // active backend substitutes its own default for an empty one.
316 let locator = var("NROS_LOCATOR");
317 let mode = match var("NROS_SESSION_MODE") {
318 Some("peer") => SessionMode::Peer,
319 _ => SessionMode::Client,
320 };
321 let domain_id = match non_empty(var("ROS_DOMAIN_ID")) {
322 // #206 — malformed is an ERROR, not a silent skip. The parse lives
323 // here rather than in the core because only this side has the text.
324 Some(s) => Some(
325 s.trim()
326 .parse::<u32>()
327 .map_err(|_| BootConfigError::DomainIdParse)?,
328 ),
329 None => None,
330 };
331 let rmw = non_empty(var("NROS_RMW")).filter(|s| s.len() <= RMW_SELECTOR_CAP);
332 Ok(EnvRung {
333 locator,
334 domain_id,
335 // Session mode has no baked rung to fall through to, so `Client` is
336 // always the answer when the variable says nothing.
337 mode: Some(mode),
338 // Issue 0794 — an EMPTY name or namespace is not a declaration: `""`
339 // is what an unset one already resolves to.
340 node_name: non_empty(var("NROS_NODE_NAME")),
341 namespace: non_empty(var("NROS_NODE_NAMESPACE")),
342 rmw,
343 })
344}
345
346/// Which rung answered one field, for the boot line.
347fn source(env: bool, baked: bool) -> &'static str {
348 if env {
349 "env"
350 } else if baked {
351 "baked"
352 } else {
353 "default"
354 }
355}
356
357/// The boot line: what each identity field resolved to, and FROM WHERE.
358///
359/// A misspelled variable does nothing, and without this line nothing says so:
360/// the image runs on its baked domain and reads as "no delivery". One line,
361/// short enough for `nros_log`'s 256-byte buffer (a body that does not fit is
362/// dropped, not truncated), so the locator is the field that can be elided.
363fn report(env: &EnvRung<'_>, baked: &BootConfig<'_>, cfg: &ExecutorConfig<'_>) {
364 let locator = if cfg.locator.is_empty() {
365 "(backend default)"
366 } else if cfg.locator.len() > 96 {
367 "(long)"
368 } else {
369 cfg.locator
370 };
371 nros_log::log_info!(
372 nros_log::get_logger("nros"),
373 "boot config: domain {} ({}), locator {} ({}), rmw {} ({})",
374 cfg.domain_id,
375 source(env.domain_id.is_some(), baked.domain_id.is_some()),
376 locator,
377 source(env.locator.is_some(), baked.locator.is_some()),
378 cfg.rmw.unwrap_or("(unique)"),
379 source(env.rmw.is_some(), baked.rmw.is_some()),
380 );
381}
382
383/// RFC-0045 precedence model A with the environment on top:
384/// `env (var set) > baked > compiled default`.
385///
386/// Fails loud on invalid identity input (repo rule: a bad domain id at boot is
387/// a configuration error, never a silent domain-0 node). FFI shims that need a
388/// return code call [`try_resolve_hosted`].
389pub fn resolve_hosted<'a>(baked: BootConfig<'a>) -> ExecutorConfig<'a> {
390 match try_resolve_hosted(baked) {
391 Ok(cfg) => cfg,
392 Err(e) => panic!("nros boot-config resolution failed: {e}"),
393 }
394}
395
396/// Fallible [`resolve_hosted`] — returns [`BootConfigError`] instead of
397/// panicking, so the C / C++ FFI shims can surface a return code.
398///
399/// - `ROS_DOMAIN_ID` set but non-numeric → `DomainIdParse` (the pre-#206 C++
400/// header silently collapsed this to domain 0; the resolver silently ignored
401/// it — both were wrong).
402/// - any resolved domain id > `DOMAIN_ID_MAX` → `DomainIdRange`, INCLUDING a
403/// baked one (the DDS backend would only fail later).
404///
405/// Logs one line naming each field's source (`report`).
406pub fn try_resolve_hosted<'a>(
407 baked: BootConfig<'a>,
408) -> Result<ExecutorConfig<'a>, BootConfigError> {
409 let env = env_rung()?;
410 let cfg = ExecutorConfig::try_resolve_with(baked, Some(env))?;
411 report(&env, &baked, &cfg);
412 Ok(cfg)
413}
414
415/// `ExecutorConfig::from_env()`, as an extension trait.
416///
417/// issue 0687 — this was an inherent method on `ExecutorConfig`, which is
418/// defined in `nros-node`; an inherent method cannot be moved to another crate
419/// and keep its spelling, and the spelling is what ~26 call sites (most of them
420/// user-facing native examples) are written against. A trait keeps
421/// `ExecutorConfig::from_env()` working wherever it is in scope — the
422/// [`prelude`](crate::prelude) carries it, so a consumer that already writes
423/// `use nros::prelude::*` changes nothing at all.
424#[cfg(feature = "env")]
425pub trait ExecutorConfigEnvExt {
426 /// Create a configuration from environment variables.
427 ///
428 /// Reads:
429 /// - `NROS_LOCATOR` — Middleware locator. Unset ⇒ empty (issue 0330: the
430 /// active RMW backend applies its own default; e.g. zenoh dials
431 /// `nros_rmw_zenoh::DEFAULT_LOCATOR`). The retired name `ZENOH_LOCATOR`
432 /// is refused (phase-484 W8c) — see [`BootConfigError::RetiredEnvVar`].
433 /// - `ROS_DOMAIN_ID` — ROS 2 domain ID (default: `0`).
434 /// - `NROS_SESSION_MODE` — `"client"` or `"peer"` (default: `"client"`).
435 /// The retired name `ZENOH_MODE` is refused (phase-484 W8c).
436 /// - `NROS_RMW` — the backend selector, through [`rmw_selector`]. issue
437 /// 0687: `Executor::open` used to read this itself; it now takes
438 /// `ExecutorConfig::rmw`, so a config built here still selects the
439 /// backend the user named.
440 ///
441 /// The returned `&'static str` fields point into the process environment,
442 /// read live on every call.
443 ///
444 /// **Panics** on a malformed or out-of-range `$ROS_DOMAIN_ID` or a set
445 /// retired alias (`ZENOH_LOCATOR` / `ZENOH_MODE`), like
446 /// [`resolve_hosted`] and the C / C++ entries. A boot identity that cannot
447 /// be resolved is a configuration error; the alternative is a node silently
448 /// running on domain 0, which is what issue #206 removed everywhere else.
449 fn from_env() -> ExecutorConfig<'static>;
450}
451
452#[cfg(feature = "env")]
453impl ExecutorConfigEnvExt for ExecutorConfig<'static> {
454 fn from_env() -> ExecutorConfig<'static> {
455 // issue 0687 — `from_env` IS `resolve_hosted` with nothing baked. The
456 // one field that does NOT come from the rung is `node_name`: `from_env`
457 // has never honoured `$NROS_NODE_NAME` (its doc lists what it reads),
458 // and callers chain `.node_name(..)` immediately.
459 ExecutorConfig {
460 node_name: "node",
461 ..resolve_hosted(BootConfig::default())
462 }
463 }
464}
465
466/// Process-wide mutex that serialises every test that touches the process
467/// environment — the ones that MUTATE it (`EnvGuard` below) and the ones that
468/// only READ it through `init()` / `Context::from_env()` (`init.rs`).
469///
470/// `cargo test` runs `#[test]`s in parallel within a single binary by default,
471/// and the reader reads the live environment on every call. So a reader that
472/// does not hold this lock can
473/// observe a sibling's `ROS_DOMAIN_ID=abc` halfway through: `init.rs`'s
474/// `plain_args_pass_through_to_init` compared two reads and got `Ok(..)` vs
475/// `Err(EnvParseFailed)` under `just ci gate`. It lived in `env.rs`'s private
476/// test module, so the readers in `init.rs` could not take it. (`cargo
477/// nextest` runs each test in its own process, so the lock is uncontended
478/// there, but taking it is still correct.)
479///
480/// Poison-TOLERANT, deliberately: three tests assert that a bad
481/// `$ROS_DOMAIN_ID` panics, and a panic while this guard is alive poisons the
482/// mutex — which would then fail every LATER test for a reason that has
483/// nothing to do with what it asserts (measured: 3 intended failures became
484/// 8). The data behind the lock is `()`; there is no invariant a panic could
485/// have broken.
486#[cfg(all(test, feature = "env"))]
487pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
488 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
489 LOCK.get_or_init(|| std::sync::Mutex::new(()))
490 .lock()
491 .unwrap_or_else(|e| e.into_inner())
492}
493
494#[cfg(all(test, feature = "env"))]
495mod tests {
496 use super::*;
497
498 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
499 super::test_env_lock()
500 }
501
502 /// RAII guard that saves and restores a single env var.
503 struct EnvGuard {
504 key: &'static str,
505 prev: Option<std::ffi::OsString>,
506 }
507
508 impl EnvGuard {
509 fn set(key: &'static str, value: &str) -> Self {
510 let prev = std::env::var_os(key);
511 // SAFETY: serialised via env_lock().
512 unsafe { std::env::set_var(key, value) };
513 Self { key, prev }
514 }
515
516 fn unset(key: &'static str) -> Self {
517 let prev = std::env::var_os(key);
518 // SAFETY: serialised via env_lock().
519 unsafe { std::env::remove_var(key) };
520 Self { key, prev }
521 }
522 }
523
524 impl Drop for EnvGuard {
525 fn drop(&mut self) {
526 // SAFETY: serialised via env_lock().
527 unsafe {
528 match &self.prev {
529 Some(v) => std::env::set_var(self.key, v),
530 None => std::env::remove_var(self.key),
531 }
532 }
533 }
534 }
535
536 /// `resolve_hosted(BootConfig::default())` must be field-for-field
537 /// identical to `from_env()`, regardless of what env vars are set.
538 #[test]
539 fn noop_resolve_matches_from_env() {
540 let _g = env_lock();
541
542 let resolved = resolve_hosted(BootConfig::default());
543 let env_cfg = ExecutorConfig::from_env();
544
545 assert_eq!(resolved.locator, env_cfg.locator);
546 assert_eq!(resolved.mode, env_cfg.mode);
547 assert_eq!(resolved.domain_id, env_cfg.domain_id);
548 assert_eq!(resolved.node_name, env_cfg.node_name);
549 assert_eq!(resolved.namespace, env_cfg.namespace);
550 assert_eq!(resolved.rmw, env_cfg.rmw);
551 }
552
553 #[test]
554 fn env_overrides_baked_on_hosted() {
555 let _g = env_lock();
556 let _e = EnvGuard::set("NROS_LOCATOR", "tcp/env:7447");
557
558 let baked = BootConfig {
559 locator: Some("tcp/baked:9999"),
560 ..Default::default()
561 };
562 let resolved = resolve_hosted(baked);
563
564 // Issue 0607 — the exact env string is observable: the reader has no
565 // process-global cache for an earlier test to have frozen.
566 assert_eq!(
567 resolved.locator, "tcp/env:7447",
568 "env locator must win over baked, with its own value"
569 );
570 }
571
572 #[test]
573 fn baked_used_when_env_unset_on_hosted() {
574 let _g = env_lock();
575 let _e1 = EnvGuard::unset("NROS_LOCATOR");
576
577 let baked = BootConfig {
578 locator: Some("tcp/baked-only:8888"),
579 ..Default::default()
580 };
581 let resolved = resolve_hosted(baked);
582
583 assert_eq!(
584 resolved.locator, "tcp/baked-only:8888",
585 "baked locator must be used when env var is absent"
586 );
587 }
588
589 #[test]
590 fn per_field_independence_baked_name_env_locator() {
591 let _g = env_lock();
592 let _e = EnvGuard::set("NROS_LOCATOR", "tcp/env:7447");
593 let _n = EnvGuard::unset("NROS_NODE_NAME");
594
595 let baked = BootConfig {
596 node_name: Some("my_talker"),
597 ..Default::default()
598 };
599 let resolved = resolve_hosted(baked);
600
601 assert_eq!(resolved.locator, "tcp/env:7447");
602 assert_eq!(
603 resolved.node_name, "my_talker",
604 "baked node_name must apply even when locator comes from env"
605 );
606 }
607
608 #[test]
609 fn try_resolve_malformed_domain_env_errors() {
610 let _l = env_lock();
611 let _g = EnvGuard::set("ROS_DOMAIN_ID", "not-a-number");
612 let err = match try_resolve_hosted(BootConfig::default()) {
613 Err(e) => e,
614 Ok(_) => panic!("expected DomainIdParse error"),
615 };
616 assert_eq!(err, BootConfigError::DomainIdParse);
617 }
618
619 /// phase-484 W8c — a retired alias REFUSES, naming its replacement, and
620 /// does so even beside a canonical value (half-migrated env is still a
621 /// mistake somebody should hear about).
622 #[test]
623 fn retired_zenoh_locator_is_refused_naming_nros_locator() {
624 let _l = env_lock();
625 let (locator_alias, _) = RETIRED_ENV_VARS[0];
626 let (mode_alias, _) = RETIRED_ENV_VARS[1];
627 let _a = EnvGuard::set(locator_alias, "tcp/legacy:7447");
628 let _b = EnvGuard::unset("NROS_LOCATOR");
629 let _c = EnvGuard::unset(mode_alias);
630 let err = match try_resolve_hosted(BootConfig::default()) {
631 Err(e) => e,
632 Ok(cfg) => panic!("expected RetiredEnvVar, resolved {:?}", cfg.locator),
633 };
634 assert_eq!(
635 err,
636 BootConfigError::RetiredEnvVar {
637 name: locator_alias,
638 replacement: "NROS_LOCATOR"
639 }
640 );
641 let msg = alloc::format!("{err}");
642 assert!(msg.contains("NROS_LOCATOR"), "{msg}");
643
644 let _d = EnvGuard::set("NROS_LOCATOR", "tcp/canonical:7447");
645 assert!(try_resolve_hosted(BootConfig::default()).is_err());
646 }
647
648 #[test]
649 fn retired_zenoh_mode_is_refused_naming_nros_session_mode() {
650 let _l = env_lock();
651 let (locator_alias, _) = RETIRED_ENV_VARS[0];
652 let (mode_alias, _) = RETIRED_ENV_VARS[1];
653 let _a = EnvGuard::unset(locator_alias);
654 let _b = EnvGuard::set(mode_alias, "peer");
655 let err = match try_resolve_hosted(BootConfig::default()) {
656 Err(e) => e,
657 Ok(_) => panic!("expected RetiredEnvVar"),
658 };
659 assert_eq!(
660 err,
661 BootConfigError::RetiredEnvVar {
662 name: mode_alias,
663 replacement: "NROS_SESSION_MODE"
664 }
665 );
666 }
667
668 /// Positive control for the two refusals above: the canonical name alone
669 /// resolves, with its own value.
670 #[test]
671 fn canonical_nros_locator_resolves_when_no_retired_alias_set() {
672 let _l = env_lock();
673 let _a = EnvGuard::unset(RETIRED_ENV_VARS[0].0);
674 let _b = EnvGuard::unset(RETIRED_ENV_VARS[1].0);
675 let _c = EnvGuard::set("NROS_LOCATOR", "tcp/canonical:7447");
676 let cfg = try_resolve_hosted(BootConfig::default()).expect("canonical resolves");
677 assert_eq!(cfg.locator, "tcp/canonical:7447");
678 }
679
680 #[test]
681 fn try_resolve_domain_env_over_max_errors() {
682 let _l = env_lock();
683 let _g = EnvGuard::set("ROS_DOMAIN_ID", "233");
684 let err = match try_resolve_hosted(BootConfig::default()) {
685 Err(e) => e,
686 Ok(_) => panic!("expected DomainIdRange error"),
687 };
688 assert_eq!(err, BootConfigError::DomainIdRange);
689 }
690
691 #[test]
692 fn try_resolve_node_name_env_rung() {
693 let _l = env_lock();
694 let _g = EnvGuard::set("NROS_NODE_NAME", "env_node");
695 let baked = BootConfig {
696 node_name: Some("baked_node"),
697 ..BootConfig::default()
698 };
699 let cfg = match try_resolve_hosted(baked) {
700 Ok(c) => c,
701 Err(e) => panic!("resolve failed: {e}"),
702 };
703 assert_eq!(
704 cfg.node_name, "env_node",
705 "env rung must override baked with its own value"
706 );
707 }
708
709 /// issue 0794 — the hosted edge fills the namespace rung.
710 ///
711 /// Before, `EnvRung` had no namespace field, so this variable could not
712 /// exist: a hosted node's namespace came from the baked rung or from a
713 /// hand-written `ExecutorConfig::namespace(..)` — the per-board lore
714 /// RFC-0045 exists to abolish.
715 #[test]
716 fn try_resolve_namespace_env_rung() {
717 let _l = env_lock();
718 let baked = BootConfig {
719 namespace: Some("/baked"),
720 ..BootConfig::default()
721 };
722
723 let g = EnvGuard::set("NROS_NODE_NAMESPACE", "/robot1");
724 let cfg = try_resolve_hosted(baked).expect("resolve ok");
725 assert_eq!(
726 cfg.namespace, "/robot1",
727 "env rung must override the baked namespace with its own value"
728 );
729 drop(g);
730
731 // Unset falls through to baked, which is the rung below it.
732 let _g = EnvGuard::unset("NROS_NODE_NAMESPACE");
733 let cfg = try_resolve_hosted(baked).expect("resolve ok");
734 assert_eq!(cfg.namespace, "/baked");
735
736 // EMPTY is unset, not "override back to root": `""` is already what an
737 // unset namespace resolves to, so honouring it would give `NROS_NODE_
738 // NAMESPACE=` a meaning no caller can distinguish from absence.
739 let _g = EnvGuard::set("NROS_NODE_NAMESPACE", "");
740 let cfg = try_resolve_hosted(baked).expect("resolve ok");
741 assert_eq!(cfg.namespace, "/baked");
742 }
743
744 /// issue 0687 — the selector reaches the config, which is how
745 /// `Executor::open` still honours `$NROS_RMW` now that it does not read the
746 /// environment itself. Both halves are asserted: a set value arrives, and
747 /// an EMPTY value means unset rather than "a backend named the empty
748 /// string" (which is what `nros-c` used to pass through).
749 #[test]
750 fn selector_reaches_the_config() {
751 let _l = env_lock();
752
753 let _g = EnvGuard::set("NROS_RMW", "cyclonedds");
754 assert_eq!(rmw_selector().as_deref(), Some("cyclonedds"));
755 assert_eq!(
756 ExecutorConfig::from_env().rmw,
757 Some("cyclonedds"),
758 "from_env must carry the selector"
759 );
760 assert_eq!(
761 resolve_hosted(BootConfig::default()).rmw,
762 Some("cyclonedds")
763 );
764
765 let _g = EnvGuard::set("NROS_RMW", "");
766 assert_eq!(rmw_selector(), None, "empty means unset");
767 assert_eq!(ExecutorConfig::from_env().rmw, None);
768 }
769
770 /// issue 0687 — `from_env` and `resolve_hosted(default)` are the same
771 /// resolution, and this asserts the fields the type does not force.
772 #[test]
773 fn from_env_is_resolve_hosted_with_nothing_baked() {
774 let _l = env_lock();
775 let _g = EnvGuard::set("NROS_LOCATOR", "tcp/agree:7447");
776 let _d = EnvGuard::set("ROS_DOMAIN_ID", "42");
777
778 let a = ExecutorConfig::from_env();
779 let b = resolve_hosted(BootConfig::default());
780 assert_eq!(a.locator, b.locator);
781 assert_eq!(a.domain_id, b.domain_id);
782 assert_eq!(a.mode, b.mode);
783 assert_eq!(a.rmw, b.rmw);
784 assert_eq!(a.namespace, b.namespace);
785 // The one deliberate divergence: `from_env` does not take the node name
786 // from the environment, and never has.
787 assert_eq!(a.node_name, "node");
788 }
789
790 /// A malformed `$ROS_DOMAIN_ID` must fail LOUD here, as it does for
791 /// `resolve_hosted` and the C / C++ entries. It used to resolve to domain 0
792 /// silently — the #206 defect, surviving on this one path.
793 #[test]
794 #[should_panic(expected = "boot-config resolution failed")]
795 fn from_env_rejects_a_malformed_domain() {
796 let _l = env_lock();
797 let _g = EnvGuard::set("ROS_DOMAIN_ID", "not-a-number");
798 let _ = ExecutorConfig::from_env();
799 }
800
801 /// …and an out-of-range one, which the same path used to coerce to 0.
802 #[test]
803 #[should_panic(expected = "boot-config resolution failed")]
804 fn from_env_rejects_an_out_of_range_domain() {
805 let _l = env_lock();
806 let _g = EnvGuard::set("ROS_DOMAIN_ID", "300");
807 let _ = ExecutorConfig::from_env();
808 }
809
810 /// A selector longer than the executor's identity capacity cannot name a
811 /// registry slot, so it is unset rather than truncated into some other
812 /// backend's name.
813 #[test]
814 fn overlong_selector_is_unset_not_truncated() {
815 let _l = env_lock();
816 let long = "x".repeat(RMW_SELECTOR_CAP + 1);
817 let _g = EnvGuard::set("NROS_RMW", &long);
818 assert_eq!(rmw_selector(), None);
819 }
820}