Skip to main content

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}