Skip to main content

subc_os/
launch_nonce.rs

1//! The launch nonce: the secret the daemon gives each module it spawns, which
2//! the module presents to be admitted as itself.
3//!
4//! On macOS and Linux the daemon hands it over through a pipe rather than
5//! the environment, because any process of the same user can read another
6//! process's initial environment (`ps eww`, `sysctl KERN_PROCARGS2`). The
7//! daemon side is [`LaunchNonceHandoff`]: a pipe that already holds the nonce,
8//! whose read end becomes descriptor [`LAUNCH_NONCE_FD`] in the child. The
9//! module side is [`launch_nonce`]: it reads that descriptor once, closes it,
10//! and caches the value for the life of the process.
11//!
12//! Windows uses a one-time named pipe authenticated to the direct child's PID.
13//! The daemon still sets the Windows environment copy for readers that have
14//! not adopted the pipe. `SUBC_LAUNCH_NONCE_PIPE_FALLBACK=env` explicitly permits
15//! environment startup when delivery fails; unmarked readers remain fail-closed.
16
17use std::{
18    ffi::OsString,
19    fmt,
20    sync::{atomic::AtomicUsize, OnceLock},
21};
22
23/// The descriptor number the pipe's read end has in the child.
24pub const LAUNCH_NONCE_FD: i32 = 3;
25
26/// Names the descriptor holding the nonce, as `<fd>:<inode>`. The inode names
27/// the pipe itself, so the reader can tell it from an unrelated descriptor
28/// that happens to have the same number. A process a module spawns inherits
29/// this variable but not the pipe, and without the inode it would read and
30/// close whatever that process has at the number.
31pub const LAUNCH_NONCE_FD_ENV: &str = "SUBC_LAUNCH_NONCE_FD";
32
33/// Names the one-time Windows pipe. Its name is public, not a credential.
34pub const LAUNCH_NONCE_PIPE_ENV: &str = "SUBC_LAUNCH_NONCE_PIPE";
35
36/// Explicit Windows rollout permission to use the environment copy if pipe
37/// delivery fails. The daemon sets this to `env` while environment-only modules
38/// migrate to pipe reads; it must be removed with the environment copy.
39pub const LAUNCH_NONCE_PIPE_FALLBACK_ENV: &str = "SUBC_LAUNCH_NONCE_PIPE_FALLBACK";
40
41/// The environment copy of the nonce, kept only while modules move to the
42/// descriptor. Same name as `subc_protocol::SUBC_LAUNCH_NONCE_ENV`; this crate
43/// does not depend on subc-protocol, so it states the name itself.
44pub const LAUNCH_NONCE_ENV: &str = "SUBC_LAUNCH_NONCE";
45
46/// Where a process got its launch nonce from.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum LaunchNonceSource {
50    /// The inherited descriptor named by [`LAUNCH_NONCE_FD_ENV`].
51    Fd,
52    /// The PID-authenticated Windows pipe named by [`LAUNCH_NONCE_PIPE_ENV`].
53    Pipe,
54    /// The environment variable [`LAUNCH_NONCE_ENV`].
55    Env,
56}
57
58impl LaunchNonceSource {
59    /// The name modules report in their provenance.
60    pub fn as_str(self) -> &'static str {
61        match self {
62            Self::Fd => "fd",
63            Self::Pipe => "pipe",
64            Self::Env => "env",
65        }
66    }
67}
68
69/// The nonce this process was launched with, and where it came from.
70/// `Debug` never prints the value.
71#[derive(Clone, PartialEq, Eq)]
72pub struct LaunchNonce {
73    value: String,
74    source: LaunchNonceSource,
75}
76
77impl LaunchNonce {
78    pub fn value(&self) -> &str {
79        &self.value
80    }
81
82    pub fn source(&self) -> LaunchNonceSource {
83        self.source
84    }
85}
86
87impl fmt::Debug for LaunchNonce {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.debug_struct("LaunchNonce")
90            .field(
91                "value",
92                &format_args!("<{} bytes redacted>", self.value.len()),
93            )
94            .field("source", &self.source)
95            .finish()
96    }
97}
98
99/// Why a named launch-nonce handoff gave no nonce.
100///
101/// These errors never fall back unless a Windows daemon explicitly permits
102/// an environment copy via [`LAUNCH_NONCE_PIPE_FALLBACK_ENV`].
103/// A named descriptor or pipe
104/// that cannot be read means the handoff went wrong, or that this process
105/// inherited the variable from a module without inheriting the pipe; reading
106/// the environment instead would hide a failed handoff or admit a descendant
107/// using its parent's environment credential.
108#[derive(Debug, Clone, PartialEq, Eq)]
109#[non_exhaustive]
110pub enum LaunchNonceError {
111    /// The variable is not `<fd>:<inode>`.
112    Malformed { value: String },
113    /// Nothing is open at that number: the variable was inherited without
114    /// the descriptor, which is what a process spawned by a module sees.
115    NotOpen { fd: i32, errno: i32 },
116    /// The descriptor is open but is not a pipe. It was left alone.
117    NotAPipe { fd: i32 },
118    /// The descriptor is a pipe, but not the one named. It was left alone.
119    WrongPipe {
120        fd: i32,
121        expected_inode: u64,
122        found_inode: u64,
123    },
124    /// The named pipe holds no bytes. It was left open and unread.
125    Empty { fd: i32 },
126    /// Reading the named pipe failed.
127    Unreadable { fd: i32, errno: Option<i32> },
128    /// The named pipe held bytes that are not UTF-8.
129    NotUtf8 { fd: i32 },
130    /// The named Windows pipe could not be opened within the busy retry window.
131    PipeNotOpen { errno: i32 },
132    /// The named Windows pipe held no bytes.
133    PipeEmpty,
134    /// Reading the Windows pipe failed or exceeded its deadline.
135    PipeUnreadable { errno: Option<i32> },
136    /// The Windows pipe held bytes that are not UTF-8.
137    PipeNotUtf8,
138}
139
140impl fmt::Display for LaunchNonceError {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        match self {
143            Self::PipeNotOpen { errno } => write!(f, "{LAUNCH_NONCE_PIPE_ENV} names a pipe that could not be opened (errno {errno})"),
144            Self::PipeEmpty => write!(f, "the launch nonce named pipe is empty"),
145            Self::PipeUnreadable { errno } => write!(f, "could not read the launch nonce from the named pipe (errno {errno:?})"),
146            Self::PipeNotUtf8 => write!(f, "the launch nonce named pipe held bytes that are not UTF-8"),
147            Self::Malformed { value } => write!(
148                f,
149                "{LAUNCH_NONCE_FD_ENV}={value:?} is not <fd>:<inode>"
150            ),
151            Self::NotOpen { fd, errno } => write!(
152                f,
153                "{LAUNCH_NONCE_FD_ENV} names descriptor {fd}, which is not open (errno {errno}); \
154                 a process spawned by a module inherits the variable but not the descriptor"
155            ),
156            Self::NotAPipe { fd } => write!(
157                f,
158                "{LAUNCH_NONCE_FD_ENV} names descriptor {fd}, which is not a pipe; left it untouched"
159            ),
160            Self::WrongPipe {
161                fd,
162                expected_inode,
163                found_inode,
164            } => write!(
165                f,
166                "{LAUNCH_NONCE_FD_ENV} names descriptor {fd} with inode {expected_inode}, but it has \
167                 inode {found_inode}; left it untouched"
168            ),
169            Self::Empty { fd } => write!(
170                f,
171                "the launch nonce pipe at descriptor {fd} is empty; left it untouched"
172            ),
173            Self::Unreadable { fd, errno } => write!(
174                f,
175                "could not read the launch nonce from descriptor {fd} (errno {errno:?})"
176            ),
177            Self::NotUtf8 { fd } => write!(
178                f,
179                "the launch nonce pipe at descriptor {fd} held bytes that are not UTF-8"
180            ),
181        }
182    }
183}
184
185impl std::error::Error for LaunchNonceError {}
186
187type Cached = Result<Option<LaunchNonce>, LaunchNonceError>;
188
189/// A launch nonce read at most once. The process has one, behind
190/// [`launch_nonce`]; tests make their own with a stand-in for the
191/// environment.
192pub(crate) struct LaunchNonceCell {
193    value: OnceLock<Cached>,
194    descriptor_reads: AtomicUsize,
195}
196
197impl LaunchNonceCell {
198    pub(crate) const fn new() -> Self {
199        Self {
200            value: OnceLock::new(),
201            descriptor_reads: AtomicUsize::new(0),
202        }
203    }
204
205    /// The cached result, reading it first if no caller has yet. Concurrent
206    /// first callers wait for the one that reads, so nobody reads twice.
207    pub(crate) fn get(&self, lookup: impl FnMut(&str) -> Option<OsString>) -> Cached {
208        self.value
209            .get_or_init(|| read_launch_nonce(lookup, &self.descriptor_reads))
210            .clone()
211    }
212
213    /// How many times this cell has taken a descriptor. At most one.
214    #[cfg(all(test, unix))]
215    pub(crate) fn descriptor_reads(&self) -> usize {
216        self.descriptor_reads
217            .load(std::sync::atomic::Ordering::SeqCst)
218    }
219}
220
221static PROCESS_NONCE: LaunchNonceCell = LaunchNonceCell::new();
222
223/// This process's launch nonce and where it came from.
224///
225/// The first call decides, and every later call returns the same answer
226/// without touching the descriptor or the environment again. So every reader
227/// in a process must come through here: after the first read closes the
228/// descriptor, its number is the next one the process hands out, and a second
229/// independent reader would read and close some unrelated socket or file.
230///
231/// - When [`LAUNCH_NONCE_FD_ENV`] is set (macOS and Linux), the descriptor it
232///   names is taken only if it is a pipe with the named inode and holds
233///   bytes; it is then read to end of file and closed. Anything else is a
234///   [`LaunchNonceError`] that leaves the descriptor as it was and never
235///   falls back to the environment.
236/// - On Windows, when [`LAUNCH_NONCE_PIPE_ENV`] is set, the named pipe is opened
237///   read-only at identification impersonation level and read to EOF with a
238///   bounded deadline. Without explicit fallback permission, errors are cached
239///   and never fall back. When [`LAUNCH_NONCE_PIPE_FALLBACK_ENV`] is `env` and
240///   a nonempty environment copy exists, failed or incomplete delivery instead
241///   caches that copy and its `env` source. A matching complete read stays `pipe`.
242/// - Otherwise the value of [`LAUNCH_NONCE_ENV`] is used.
243/// - `Ok(None)` means no handoff is named (or the environment copy is empty): the
244///   process was not started by the daemon.
245///
246/// It never changes the environment. Removing either variable would break
247/// any other reader in the process still on the environment copy, and
248/// changing the environment of a multi-threaded process can race readers in
249/// libraries whose environment access cannot be synchronized by this accessor.
250///
251/// Call it before the process spawns anything. Until the first read the
252/// descriptor is inheritable (it has to be, to survive the daemon's exec),
253/// so a child spawned earlier could inherit and consume the module's secret.
254pub fn launch_nonce() -> Result<Option<LaunchNonce>, LaunchNonceError> {
255    PROCESS_NONCE.get(|key| std::env::var_os(key))
256}
257
258fn read_launch_nonce(
259    mut lookup: impl FnMut(&str) -> Option<OsString>,
260    descriptor_reads: &AtomicUsize,
261) -> Cached {
262    #[cfg(windows)]
263    if let Some(value) = lookup(LAUNCH_NONCE_PIPE_ENV) {
264        let fallback = if lookup(LAUNCH_NONCE_PIPE_FALLBACK_ENV).as_deref()
265            == Some(std::ffi::OsStr::new("env"))
266        {
267            environment_nonce(lookup(LAUNCH_NONCE_ENV))
268        } else {
269            None
270        };
271        return pipe_or_permitted_env(windows::read_pipe(&value), fallback);
272    }
273    #[cfg(unix)]
274    if let Some(value) = lookup(LAUNCH_NONCE_FD_ENV) {
275        return unix::read_descriptor(&value, descriptor_reads);
276    }
277    #[cfg(not(unix))]
278    let _ = descriptor_reads;
279    Ok(environment_nonce(lookup(LAUNCH_NONCE_ENV)))
280}
281
282fn environment_nonce(value: Option<OsString>) -> Option<LaunchNonce> {
283    value
284        .and_then(|value| value.into_string().ok())
285        .filter(|value| !value.is_empty())
286        .map(|value| LaunchNonce {
287            value,
288            source: LaunchNonceSource::Env,
289        })
290}
291
292#[cfg(any(windows, test))]
293fn pipe_or_permitted_env(pipe: Cached, fallback: Option<LaunchNonce>) -> Cached {
294    let Some(fallback) = fallback else {
295        return pipe;
296    };
297    match &pipe {
298        Ok(Some(nonce)) if nonce.value() == fallback.value() => pipe,
299        // The daemon supplies the same nonce through the pipe and
300        // SUBC_LAUNCH_NONCE until every module reads the pipe. With explicit
301        // permission to use the copy, reject even valid UTF-8 pipe bytes that
302        // differ from it: a truncated secret would otherwise fail registration.
303        _ => Ok(Some(fallback)),
304    }
305}
306
307#[cfg(unix)]
308pub use unix::LaunchNonceHandoff;
309
310#[cfg(windows)]
311pub use windows::{LaunchNoncePipeDelivery, LaunchNoncePipeHandoff};
312
313#[cfg(windows)]
314#[allow(unsafe_code)]
315mod windows;
316
317#[cfg(unix)]
318mod unix {
319    use std::{
320        ffi::OsStr,
321        fs::File,
322        io::{self, Read, Write},
323        os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
324        sync::atomic::{AtomicUsize, Ordering},
325    };
326
327    use super::{Cached, LaunchNonce, LaunchNonceError, LaunchNonceSource, LAUNCH_NONCE_FD};
328
329    /// The daemon half of the handoff, prepared before the spawn: a pipe that
330    /// already holds the nonce, its write end closed, and its inode.
331    ///
332    /// A module uses the same type to hand its own nonce to a helper process
333    /// that must connect as the module: never in argv, the environment or a
334    /// file, all of which another same-user process can read.
335    ///
336    /// ```no_run
337    /// # fn helper(nonce: &str) -> std::io::Result<()> {
338    /// use subc_os::launch_nonce::{LaunchNonceHandoff, LAUNCH_NONCE_FD_ENV};
339    ///
340    /// let mut command = std::process::Command::new("helper");
341    /// let handoff = LaunchNonceHandoff::new(nonce)?;
342    /// command.env(LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
343    /// // After every other pre-exec step the command has.
344    /// handoff.install_last(&mut command);
345    /// command.spawn()?;
346    /// # Ok(()) }
347    /// ```
348    #[derive(Debug)]
349    pub struct LaunchNonceHandoff {
350        read_end: OwnedFd,
351        inode: u64,
352        target: RawFd,
353    }
354
355    impl LaunchNonceHandoff {
356        /// Make the pipe, write `nonce` into it and close the write end, so a
357        /// reader gets exactly `nonce` and then end of file.
358        ///
359        /// Both ends are created close-on-exec (`std::io::pipe` does that),
360        /// so the read end reaches no child until [`Self::install_last`] puts
361        /// it into one. The nonce must fit in the pipe buffer, or the write
362        /// blocks with no reader: POSIX guarantees 512 bytes and macOS and
363        /// Linux give 16 KiB or more, and a daemon nonce is 64 characters.
364        pub fn new(nonce: &str) -> io::Result<Self> {
365            let (reader, mut writer) = io::pipe()?;
366            writer.write_all(nonce.as_bytes())?;
367            drop(writer);
368            let mut read_end = OwnedFd::from(reader);
369            // std configures the child's stdio before pre-exec callbacks. If a
370            // standard descriptor was closed in the parent, pipe() can use its
371            // number, which stdio setup would overwrite before the handoff runs.
372            // Move it out of that range now, keeping the parent copy close-on-exec.
373            if read_end.as_raw_fd() < LAUNCH_NONCE_FD {
374                // SAFETY: duplicates an owned descriptor; no memory is passed.
375                #[allow(unsafe_code)]
376                let copy = unsafe {
377                    libc::fcntl(read_end.as_raw_fd(), libc::F_DUPFD_CLOEXEC, LAUNCH_NONCE_FD)
378                };
379                if copy == -1 {
380                    return Err(io::Error::last_os_error());
381                }
382                // SAFETY: the successful fcntl returned a new descriptor owned here.
383                #[allow(unsafe_code)]
384                {
385                    read_end = unsafe { OwnedFd::from_raw_fd(copy) };
386                }
387            }
388            let inode = fstat(read_end.as_raw_fd())?.st_ino as u64;
389            Ok(Self {
390                read_end,
391                inode,
392                target: LAUNCH_NONCE_FD,
393            })
394        }
395
396        /// The value to give the child as
397        /// [`LAUNCH_NONCE_FD_ENV`](super::LAUNCH_NONCE_FD_ENV):
398        /// `3:<inode of this pipe>`.
399        pub fn fd_env_value(&self) -> String {
400            format!("{}:{}", self.target, self.inode)
401        }
402
403        /// Arrange for the read end to be descriptor 3 in the process
404        /// `command` spawns, and in no other process. For a tokio `Command`,
405        /// pass `command.as_std_mut()`.
406        ///
407        /// It must be the LAST pre-exec step registered. The standard library
408        /// runs pre-exec steps in registration order, after its own stdio
409        /// setup, and this one closes whatever the child had at descriptor 3.
410        /// A step that runs later and writes through a descriptor it captured
411        /// (the Linux cgroup placement writes to `cgroup.procs`) would find
412        /// the pipe there instead if that descriptor had number 3.
413        ///
414        /// Registering any pre-exec step makes the standard library fork and
415        /// exec instead of using `posix_spawn`, on macOS as on Linux.
416        pub fn install_last(self, command: &mut std::process::Command) {
417            use std::os::unix::process::CommandExt;
418            // SAFETY: the closure runs between fork and exec in a copy of a
419            // possibly multi-threaded process, where only async-signal-safe
420            // calls are sound. `install_in_child` makes only dup2 and fcntl
421            // calls on a descriptor opened before the fork, and allocates
422            // nothing (see the_pre_exec_step_does_not_allocate).
423            #[allow(unsafe_code)]
424            unsafe {
425                command.pre_exec(move || self.install_in_child());
426            }
427        }
428
429        /// Put the read end at the target number without close-on-exec. Runs
430        /// in the forked child: only dup2 and fcntl, and errors built from
431        /// errno, which does not allocate.
432        ///
433        /// When the read end already has the target number, `dup2` would do
434        /// nothing and leave close-on-exec set, so exec would close the
435        /// descriptor; that case clears the flag instead.
436        pub(crate) fn install_in_child(&self) -> io::Result<()> {
437            let source = self.read_end.as_raw_fd();
438            if source == self.target {
439                // SAFETY: fcntl on a descriptor this struct owns; no memory is passed.
440                #[allow(unsafe_code)]
441                let flags = unsafe { libc::fcntl(source, libc::F_GETFD) };
442                if flags == -1 {
443                    return Err(io::Error::last_os_error());
444                }
445                // SAFETY: as above.
446                #[allow(unsafe_code)]
447                let set = unsafe { libc::fcntl(source, libc::F_SETFD, flags & !libc::FD_CLOEXEC) };
448                if set == -1 {
449                    return Err(io::Error::last_os_error());
450                }
451                return Ok(());
452            }
453            // SAFETY: dup2 takes two integers and touches no memory. It closes
454            // whatever the child had at the target, which is why this step
455            // must run after every other one (see `install_last`). The new
456            // descriptor does not carry close-on-exec, so it survives exec.
457            #[allow(unsafe_code)]
458            if unsafe { libc::dup2(source, self.target) } == -1 {
459                return Err(io::Error::last_os_error());
460            }
461            Ok(())
462        }
463
464        /// A handoff whose read end is placed at `target` instead of 3, so
465        /// tests can exercise the step without claiming descriptor 3 in the
466        /// test process itself.
467        #[cfg(test)]
468        pub(crate) fn with_target(mut self, target: RawFd) -> Self {
469            self.target = target;
470            self
471        }
472
473        #[cfg(test)]
474        pub(crate) fn read_end_fd(&self) -> RawFd {
475            self.read_end.as_raw_fd()
476        }
477
478        #[cfg(test)]
479        pub(crate) fn inode(&self) -> u64 {
480            self.inode
481        }
482    }
483
484    pub(super) fn read_descriptor(value: &OsStr, descriptor_reads: &AtomicUsize) -> Cached {
485        let text = value.to_string_lossy();
486        let malformed = || LaunchNonceError::Malformed {
487            value: text.to_string(),
488        };
489        let (fd_text, inode_text) = text.split_once(':').ok_or_else(malformed)?;
490        let fd: RawFd = fd_text.parse().map_err(|_| malformed())?;
491        let expected_inode: u64 = inode_text.parse().map_err(|_| malformed())?;
492        if fd < 0 {
493            return Err(malformed());
494        }
495
496        // Check what the descriptor is before taking ownership of it. Reading
497        // and closing a descriptor that belongs to other code in this process
498        // would break that code, and a process that inherited the variable
499        // without the descriptor may well have something else at this number.
500        let stat = fstat(fd).map_err(|error| LaunchNonceError::NotOpen {
501            fd,
502            errno: error.raw_os_error().unwrap_or(0),
503        })?;
504        if stat.st_mode & libc::S_IFMT != libc::S_IFIFO {
505            return Err(LaunchNonceError::NotAPipe { fd });
506        }
507        let found_inode = stat.st_ino as u64;
508        if found_inode != expected_inode {
509            return Err(LaunchNonceError::WrongPipe {
510                fd,
511                expected_inode,
512                found_inode,
513            });
514        }
515        // Ask how many bytes are waiting rather than reading to find out, so
516        // an empty pipe is refused without being consumed or closed.
517        let mut waiting: libc::c_int = 0;
518        // SAFETY: FIONREAD writes one int through the pointer, which points
519        // at a live local of that type.
520        #[allow(unsafe_code)]
521        if unsafe { libc::ioctl(fd, libc::FIONREAD, &mut waiting) } == -1 {
522            return Err(LaunchNonceError::Unreadable {
523                fd,
524                errno: io::Error::last_os_error().raw_os_error(),
525            });
526        }
527        if waiting <= 0 {
528            return Err(LaunchNonceError::Empty { fd });
529        }
530
531        descriptor_reads.fetch_add(1, Ordering::SeqCst);
532        // SAFETY: the descriptor is open and is the pipe the daemon named by
533        // inode, so it was handed to this process for this read. Nothing else
534        // in the process reads it: every reader goes through the one cached
535        // accessor, which reaches this line at most once.
536        #[allow(unsafe_code)]
537        let mut file = File::from(unsafe { OwnedFd::from_raw_fd(fd) });
538        let mut bytes = Vec::with_capacity(64);
539        let read = file.read_to_end(&mut bytes);
540        drop(file);
541        read.map_err(|error| LaunchNonceError::Unreadable {
542            fd,
543            errno: error.raw_os_error(),
544        })?;
545        let value = String::from_utf8(bytes).map_err(|_| LaunchNonceError::NotUtf8 { fd })?;
546        Ok(Some(LaunchNonce {
547            value,
548            source: LaunchNonceSource::Fd,
549        }))
550    }
551
552    pub(super) fn fstat(fd: RawFd) -> io::Result<libc::stat> {
553        let mut stat = std::mem::MaybeUninit::<libc::stat>::uninit();
554        // SAFETY: fstat writes one `struct stat` into the buffer, which is
555        // exactly that size, and writes nothing when it fails.
556        #[allow(unsafe_code)]
557        if unsafe { libc::fstat(fd, stat.as_mut_ptr()) } == -1 {
558            return Err(io::Error::last_os_error());
559        }
560        // SAFETY: fstat succeeded, so it filled the buffer.
561        #[allow(unsafe_code)]
562        Ok(unsafe { stat.assume_init() })
563    }
564}
565
566#[cfg(test)]
567pub(crate) mod tests;
568
569#[cfg(test)]
570mod pipe_rollout_tests {
571    use super::*;
572
573    fn nonce(value: &str, source: LaunchNonceSource) -> LaunchNonce {
574        LaunchNonce {
575            value: value.to_owned(),
576            source,
577        }
578    }
579
580    #[test]
581    fn phase_one_pipe_failure_uses_the_permitted_environment_source() {
582        let result = pipe_or_permitted_env(
583            Err(LaunchNonceError::PipeEmpty),
584            Some(nonce("env-secret", LaunchNonceSource::Env)),
585        );
586        let value = result.unwrap().unwrap();
587        assert_eq!(value.value(), "env-secret");
588        assert_eq!(value.source(), LaunchNonceSource::Env);
589    }
590
591    #[test]
592    fn phase_one_partial_pipe_nonce_uses_the_permitted_environment_source() {
593        let result = pipe_or_permitted_env(
594            Ok(Some(nonce("partial", LaunchNonceSource::Pipe))),
595            Some(nonce("env-secret", LaunchNonceSource::Env)),
596        );
597        let value = result.unwrap().unwrap();
598        assert_eq!(value.value(), "env-secret");
599        assert_eq!(value.source(), LaunchNonceSource::Env);
600    }
601
602    #[test]
603    fn phase_one_complete_pipe_nonce_keeps_the_pipe_source() {
604        let result = pipe_or_permitted_env(
605            Ok(Some(nonce("same-secret", LaunchNonceSource::Pipe))),
606            Some(nonce("same-secret", LaunchNonceSource::Env)),
607        );
608        let value = result.unwrap().unwrap();
609        assert_eq!(value.value(), "same-secret");
610        assert_eq!(value.source(), LaunchNonceSource::Pipe);
611    }
612
613    #[test]
614    fn strict_pipe_failure_without_permission_stays_an_error() {
615        assert_eq!(
616            pipe_or_permitted_env(Err(LaunchNonceError::PipeEmpty), None),
617            Err(LaunchNonceError::PipeEmpty)
618        );
619    }
620}