Skip to main content

rightkit_daemon/
host_identity.rs

1//! Verifying the HOST from the client side.
2//!
3//! The server side of this crate has always checked its peer: the kernel says the client runs as
4//! the same user. A client used to check only the mirror image of that, that whatever answered at
5//! the recorded endpoint also runs as the same user. That says nothing about WHICH program it is,
6//! so a program that managed to get the client to dial it (by redirecting the suite root, by
7//! writing a `service.json`, or by binding the socket path first) received the client's `hello`
8//! and every request after it, including any secret in them.
9//!
10//! [`HostExpectation`] closes that. The caller names the executable(s) the host may be;
11//! [`Client::connect_expecting`](crate::Client::connect_expecting) asks the kernel who is on the
12//! listening end of the connected socket and refuses anyone else before a single application
13//! byte is written. See the README section "Verifying the host" for the threat model.
14
15use std::ffi::OsStr;
16use std::fmt;
17use std::io;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21/// The process the OS says is on the listening end of a connection this client opened, as it was
22/// when every check passed. Returned by
23/// [`Client::verified_host`](crate::Client::verified_host) and handed to a caller-supplied
24/// verifier ([`HostExpectation::with_verifier`]).
25#[derive(Debug, Clone, PartialEq, Eq)]
26#[non_exhaustive]
27pub struct HostProcess {
28    /// The host's process id as the kernel recorded it for the socket. A pid names a process
29    /// only while it runs: use it for an audit trail, not as a credential.
30    pub pid: u32,
31    /// Unix: the host's effective uid (always the client's own). Windows: `None`.
32    pub uid: Option<u32>,
33    /// Windows: the host process token's user SID. Unix: `None`.
34    pub sid: Option<String>,
35    /// The host's executable path as the kernel reports it for `pid` (macOS `proc_pidpath`,
36    /// Linux `/proc/<pid>/exe`), canonicalised when it still exists on disk.
37    pub executable: PathBuf,
38    /// macOS: the host's audit token (`audit_token_t`, eight words: pid at index 5, pid version
39    /// at index 7) from `LOCAL_PEERTOKEN`, when the kernel provides it. It is the race-free
40    /// handle for code-signature checks (`SecCodeCopyGuestWithAttributes` with
41    /// `kSecGuestAttributeAudit`), which a [`HostExpectation::with_verifier`] closure can use.
42    /// `None` elsewhere.
43    pub audit_token: Option<[u32; 8]>,
44}
45
46/// Why a host was refused. Always the host's identity, never a connection problem.
47#[derive(Debug, Clone, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum HostIdentityReason {
50    /// The expectation names no executable, so no host can match. Fails closed.
51    NoExpectation,
52    /// The host runs as another user than this client.
53    UidMismatch { host: Option<u32>, client: u32 },
54    /// The OS did not report the host's process id (a BSD, or a pid namespace that hides it).
55    PidUnavailable,
56    /// The kernel would not describe the host process (permission, or the call failed).
57    Unreadable(String),
58    /// The host's executable is not one of the expected executables.
59    ExecutableMismatch,
60    /// The process behind the socket exited or changed while it was being identified, so the pid
61    /// could not be proven to name one process throughout.
62    HostChanged(String),
63    /// The executable matched but the caller's extra verification refused the host.
64    Refused(String),
65    /// Identifying the host is not implemented for this platform. Fails closed: it never means
66    /// "assume it is the right one".
67    Unverified {
68        platform: &'static str,
69        detail: &'static str,
70    },
71    /// The OS could not answer the peer-credential query.
72    Query(String),
73}
74
75impl fmt::Display for HostIdentityReason {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        match self {
78            HostIdentityReason::NoExpectation => {
79                write!(
80                    f,
81                    "no expected host executable was supplied, so no host can match"
82                )
83            }
84            HostIdentityReason::UidMismatch { host, client } => match host {
85                Some(host) => write!(
86                    f,
87                    "the host runs as uid {host}, this client as uid {client}"
88                ),
89                None => write!(
90                    f,
91                    "the host's uid is unknown, this client runs as uid {client}"
92                ),
93            },
94            HostIdentityReason::PidUnavailable => {
95                write!(f, "the OS did not report the host's process id")
96            }
97            HostIdentityReason::Unreadable(why) => {
98                write!(f, "the OS would not describe the host process ({why})")
99            }
100            HostIdentityReason::ExecutableMismatch => {
101                write!(
102                    f,
103                    "the host's executable is not one of the expected executables"
104                )
105            }
106            HostIdentityReason::HostChanged(why) => {
107                write!(
108                    f,
109                    "the host process changed while it was identified ({why})"
110                )
111            }
112            HostIdentityReason::Refused(why) => {
113                write!(f, "the caller's extra host verification refused it ({why})")
114            }
115            HostIdentityReason::Unverified { platform, detail } => {
116                write!(
117                    f,
118                    "host verification is unavailable on {platform} ({detail})"
119                )
120            }
121            HostIdentityReason::Query(why) => {
122                write!(f, "the OS could not identify the peer ({why})")
123            }
124        }
125    }
126}
127
128/// The process behind the endpoint is not the host the caller expected. No `hello` and no request
129/// frame was written to it.
130///
131/// Distinct from a connection error: something answered, and the OS says it is the wrong program.
132/// It is not retryable (reconnecting reaches the same program). Content-free: pids and paths
133/// only, never request data.
134#[derive(Debug, Clone, PartialEq, Eq)]
135#[non_exhaustive]
136pub struct HostIdentityError {
137    /// The executables the caller said the host may be, as supplied (not canonicalised).
138    pub expected: Vec<PathBuf>,
139    /// The pid the kernel reported for the host, when it reported one.
140    pub found_pid: Option<u32>,
141    /// The host's executable, when it could be read before the refusal.
142    pub found_executable: Option<PathBuf>,
143    pub reason: HostIdentityReason,
144}
145
146impl fmt::Display for HostIdentityError {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        write!(
149            f,
150            "the service host failed identity verification: {}",
151            self.reason
152        )?;
153        if let Some(pid) = self.found_pid {
154            write!(f, "; host pid {pid}")?;
155        }
156        if let Some(exe) = &self.found_executable {
157            write!(f, "; host executable {}", exe.display())?;
158        }
159        if self.expected.is_empty() {
160            write!(f, "; expected: none supplied")
161        } else {
162            write!(f, "; expected ")?;
163            for (i, path) in self.expected.iter().enumerate() {
164                if i > 0 {
165                    write!(f, " or ")?;
166                }
167                write!(f, "{}", path.display())?;
168            }
169            Ok(())
170        }
171    }
172}
173
174impl std::error::Error for HostIdentityError {}
175
176/// Internal: why opening a host-verified connection failed.
177#[derive(Debug)]
178pub(crate) enum HostConnectError {
179    /// The connection itself failed, or the host left before it could be identified.
180    Io(io::Error),
181    /// The host answered and is not the expected one.
182    Identity(HostIdentityError),
183}
184
185type Verifier = Arc<dyn Fn(&HostProcess) -> Result<(), String> + Send + Sync>;
186
187/// Which program the client is willing to talk to, passed to
188/// [`Client::connect_expecting`](crate::Client::connect_expecting).
189///
190/// The host must run as the client's own user (always) and its executable, read from the kernel
191/// for the connected socket's process, must be one of the paths named here. Both sides of the
192/// comparison are canonicalised when the check runs, so a symlink, a `..` or a case difference on
193/// a case-insensitive volume does not matter; the same file reached by another name (a hard link)
194/// is the same executable.
195///
196/// An expectation with no path matches nothing: it fails closed.
197///
198/// The install-relative constructors ([`sibling_executable`](Self::sibling_executable),
199/// [`bundle_executable`](Self::bundle_executable),
200/// [`relative_to_current_exe`](Self::relative_to_current_exe)) derive the host's path from where
201/// THIS process's executable lives, so an engine shipped inside an app can name its app without a
202/// hard-coded path. They inherit the trust of that location: see the README, "What this does not
203/// do".
204#[derive(Clone, Default)]
205pub struct HostExpectation {
206    executables: Vec<PathBuf>,
207    verifier: Option<Verifier>,
208}
209
210impl fmt::Debug for HostExpectation {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        f.debug_struct("HostExpectation")
213            .field("executables", &self.executables)
214            .field("verifier", &self.verifier.as_ref().map(|_| "<fn>"))
215            .finish()
216    }
217}
218
219fn invalid(what: &str) -> io::Error {
220    io::Error::new(io::ErrorKind::InvalidInput, what.to_string())
221}
222
223fn current_exe_canonical() -> io::Result<PathBuf> {
224    std::fs::canonicalize(std::env::current_exe()?)
225}
226
227/// A bare file name: one normal component, no separators, no `..`.
228fn is_single_component(name: &OsStr) -> bool {
229    let path = Path::new(name);
230    path.file_name() == Some(name) && path.components().count() == 1
231}
232
233/// `<ancestor of exe, levels_up levels above it>/<relative>`. `levels_up` 1 is the directory that
234/// holds `exe`. `None` for 0 levels, a path with no such ancestor, or a `relative` that is not
235/// relative: absolute, or (Windows) rooted without a drive (`\x`, `/x`) or carrying a drive
236/// (`C:x`), each of which `join` would let replace the base.
237fn up_and_join(exe: &Path, levels_up: usize, relative: &Path) -> Option<PathBuf> {
238    use std::path::Component;
239    let leaves_base = relative
240        .components()
241        .any(|part| matches!(part, Component::Prefix(_) | Component::RootDir));
242    if levels_up == 0 || relative.is_absolute() || leaves_base {
243        return None;
244    }
245    exe.ancestors()
246        .nth(levels_up)
247        .map(|base| base.join(relative))
248}
249
250/// `<enclosing>.app/Contents/MacOS/<file_name>` for the first `.app` directory above `exe`.
251fn bundle_main(exe: &Path, file_name: &OsStr) -> Option<PathBuf> {
252    exe.ancestors()
253        .skip(1)
254        .find(|dir| dir.extension() == Some(OsStr::new("app")))
255        .map(|bundle| bundle.join("Contents").join("MacOS").join(file_name))
256}
257
258impl HostExpectation {
259    /// The host is the program at `path`.
260    pub fn executable(path: impl Into<PathBuf>) -> Self {
261        Self {
262            executables: vec![path.into()],
263            verifier: None,
264        }
265    }
266
267    /// The host is any one of `paths` (a release path and a QA path, say). Empty matches nothing.
268    pub fn any_of<I, P>(paths: I) -> Self
269    where
270        I: IntoIterator<Item = P>,
271        P: Into<PathBuf>,
272    {
273        Self {
274            executables: paths.into_iter().map(Into::into).collect(),
275            verifier: None,
276        }
277    }
278
279    /// Also accept the program at `path`.
280    pub fn or_executable(mut self, path: impl Into<PathBuf>) -> Self {
281        self.executables.push(path.into());
282        self
283    }
284
285    /// The host is the executable `file_name` in the same directory as THIS process's executable
286    /// (symlinks resolved first). Fails when the running executable cannot be located or
287    /// `file_name` is not a bare file name.
288    pub fn sibling_executable(file_name: impl AsRef<OsStr>) -> io::Result<Self> {
289        let name = file_name.as_ref();
290        if !is_single_component(name) {
291            return Err(invalid("the host file name must be a bare file name"));
292        }
293        Self::relative_to_current_exe(1, name)
294    }
295
296    /// The host is the main executable `file_name` of the macOS app bundle that contains THIS
297    /// process's executable: `<App>.app/Contents/MacOS/<file_name>`. For an engine or command
298    /// line tool shipped inside the app, whether it sits in `Contents/Resources/bin` or in
299    /// `Contents/MacOS` itself. Fails when the running executable is not inside an `.app`
300    /// directory or `file_name` is not a bare file name.
301    pub fn bundle_executable(file_name: impl AsRef<OsStr>) -> io::Result<Self> {
302        let name = file_name.as_ref();
303        if !is_single_component(name) {
304            return Err(invalid("the host file name must be a bare file name"));
305        }
306        let exe = current_exe_canonical()?;
307        let path = bundle_main(&exe, name).ok_or_else(|| {
308            io::Error::new(
309                io::ErrorKind::NotFound,
310                "the running executable is not inside an .app bundle",
311            )
312        })?;
313        Ok(Self::executable(path))
314    }
315
316    /// The host is `relative` under the directory `levels_up` levels above THIS process's
317    /// executable (symlinks resolved first; 1 is the directory that holds it). For install
318    /// layouts that are neither a sibling nor a macOS bundle, for example
319    /// `relative_to_current_exe(2, "bin/app")` for `<root>/libexec/engine` and `<root>/bin/app`.
320    pub fn relative_to_current_exe(
321        levels_up: usize,
322        relative: impl AsRef<Path>,
323    ) -> io::Result<Self> {
324        let exe = current_exe_canonical()?;
325        let path = up_and_join(&exe, levels_up, relative.as_ref()).ok_or_else(|| {
326            invalid("no such directory above the running executable, or the path is absolute")
327        })?;
328        Ok(Self::executable(path))
329    }
330
331    /// Add a check that runs AFTER the executable matched, with the host the OS identified. A
332    /// returned `Err(reason)` refuses the host as [`HostIdentityReason::Refused`]. The check runs
333    /// before the first application byte is written.
334    ///
335    /// This is the hook for a stronger identity than a path. This crate links no code-signing API,
336    /// so the macOS team-identifier check is the caller's: link Security.framework in the app,
337    /// build a `SecCode` for the host from [`HostProcess::audit_token`] with
338    /// `SecCodeCopyGuestWithAttributes` and `kSecGuestAttributeAudit` (never from the pid alone,
339    /// which can be reused), read `kSecCodeInfoTeamIdentifier` with `SecCodeCopySigningInformation`
340    /// and compare it with your own (`SecCodeCopySelf`). On Windows the same hook carries an
341    /// Authenticode (`WinVerifyTrust`) check of [`HostProcess::executable`].
342    pub fn with_verifier(
343        mut self,
344        check: impl Fn(&HostProcess) -> Result<(), String> + Send + Sync + 'static,
345    ) -> Self {
346        self.verifier = Some(Arc::new(check));
347        self
348    }
349
350    /// The executables this expectation names, as supplied.
351    pub fn executables(&self) -> &[PathBuf] {
352        &self.executables
353    }
354}
355
356#[cfg(unix)]
357pub(crate) use unix_impl::verify_unix_host;
358
359#[cfg(unix)]
360mod unix_impl {
361    use super::{
362        HostConnectError, HostExpectation, HostIdentityError, HostIdentityReason, HostProcess,
363    };
364    use crate::peer::{current_euid, verify_peer_is_current_user, PeerError};
365    use std::io;
366    use std::os::unix::fs::MetadataExt;
367    use std::os::unix::net::UnixStream;
368    use std::path::{Path, PathBuf};
369
370    /// `(device, inode)`: names one file whatever path it is reached by.
371    pub type FileId = (u64, u64);
372
373    pub fn file_id(path: &Path) -> Option<FileId> {
374        let md = std::fs::metadata(path).ok()?;
375        Some((md.dev(), md.ino()))
376    }
377
378    /// A running process's executable as the kernel reports it.
379    pub struct ExeInfo {
380        pub path: PathBuf,
381        /// The file the process is actually running, when the kernel lets us stat it (Linux:
382        /// through `/proc/<pid>/exe`, exact even if the file was replaced; macOS: by path).
383        pub id: Option<FileId>,
384    }
385
386    /// What must be unchanged across the executable-path read to prove the pid named one process
387    /// throughout: its start time, and (macOS) its audit token, which carries a pid version.
388    #[derive(PartialEq, Eq)]
389    struct Witness {
390        start: (u64, u64),
391        token: Option<[u32; 8]>,
392    }
393
394    enum ObserveError {
395        Gone,
396        Unreadable(String),
397    }
398
399    impl From<ObserveError> for HostIdentityReason {
400        fn from(e: ObserveError) -> Self {
401            match e {
402                ObserveError::Gone => HostIdentityReason::HostChanged(
403                    "it exited before it could be identified".to_string(),
404                ),
405                ObserveError::Unreadable(why) => HostIdentityReason::Unreadable(why),
406            }
407        }
408    }
409
410    /// macOS: the errno that says "no such process" (ESRCH, 3), or a stale zero, is `Ok(None)`;
411    /// anything else is an error. Read right after a `proc_*` call that returned 0.
412    #[cfg(target_os = "macos")]
413    fn missing_or_err<T>() -> io::Result<Option<T>> {
414        let e = io::Error::last_os_error();
415        match e.raw_os_error() {
416            Some(3) | Some(0) | None => Ok(None),
417            _ => Err(e),
418        }
419    }
420
421    fn witness(stream: &UnixStream, pid: u32) -> Result<Witness, ObserveError> {
422        let token = os::audit_token(stream);
423        let start = match os::start_marker(pid) {
424            Ok(Some(start)) => start,
425            Ok(None) => return Err(ObserveError::Gone),
426            Err(e) => return Err(ObserveError::Unreadable(format!("process start time: {e}"))),
427        };
428        Ok(Witness { start, token })
429    }
430
431    /// Is the running executable (`found`, canonical, with its file identity `found_id`) the one
432    /// `expected` names? Equal after canonicalisation, or the very same file (device and inode),
433    /// which covers a case-insensitive volume (macOS `realpath` does not normalise case, so
434    /// `/applications/x` and `/Applications/x` differ as strings) and a hard link. An expected
435    /// path that does not exist matches nothing.
436    pub fn same_executable(expected: &Path, found: &Path, found_id: Option<FileId>) -> bool {
437        let Ok(canonical) = std::fs::canonicalize(expected) else {
438            return false;
439        };
440        if canonical == found {
441            return true;
442        }
443        match (found_id, file_id(&canonical)) {
444            (Some(running), Some(named)) => running == named,
445            _ => false,
446        }
447    }
448
449    /// Ask the kernel who is on the listening end of `stream` and decide whether it is the host
450    /// `expect` names. Reads and writes nothing on the stream.
451    ///
452    /// Order, each step failing closed:
453    /// 1. the peer's uid equals ours (`getpeereid` / `SO_PEERCRED`, the existing same-user check);
454    /// 2. the pid the kernel reports for the socket (`LOCAL_PEERPID` / `SO_PEERCRED`);
455    /// 3. the pid's start time (and, on macOS, the audit token), then its executable path
456    ///    (`proc_pidpath` / `/proc/<pid>/exe`), then the start time and token again, then the pid
457    ///    from the socket again: all must agree, or the pid cannot be proven to name one process;
458    /// 4. the executable is one of the expected executables;
459    /// 5. the caller's extra verifier, if any.
460    pub(crate) fn verify_unix_host(
461        stream: &UnixStream,
462        expect: &HostExpectation,
463    ) -> Result<HostProcess, HostConnectError> {
464        let refuse = |pid: Option<u32>, exe: Option<PathBuf>, reason: HostIdentityReason| {
465            HostConnectError::Identity(HostIdentityError {
466                expected: expect.executables.clone(),
467                found_pid: pid,
468                found_executable: exe,
469                reason,
470            })
471        };
472        if expect.executables.is_empty() {
473            return Err(refuse(None, None, HostIdentityReason::NoExpectation));
474        }
475        if !os::SUPPORTED {
476            return Err(refuse(
477                None,
478                None,
479                HostIdentityReason::Unverified {
480                    platform: std::env::consts::OS,
481                    detail: "reading a process's executable path is not implemented for this Unix",
482                },
483            ));
484        }
485
486        let first = match verify_peer_is_current_user(stream) {
487            Ok(id) => id,
488            Err(PeerError::Mismatch(id)) => {
489                return Err(refuse(
490                    id.pid,
491                    None,
492                    HostIdentityReason::UidMismatch {
493                        host: id.uid,
494                        client: current_euid(),
495                    },
496                ))
497            }
498            Err(PeerError::Unsupported(detail)) => {
499                return Err(refuse(
500                    None,
501                    None,
502                    HostIdentityReason::Unverified {
503                        platform: std::env::consts::OS,
504                        detail,
505                    },
506                ))
507            }
508            Err(e) => {
509                // The host hung up before it could be identified: a connection problem.
510                if e.peer_gone() {
511                    return Err(HostConnectError::Io(io::Error::from(e)));
512                }
513                return Err(refuse(None, None, HostIdentityReason::Query(e.to_string())));
514            }
515        };
516        let Some(pid) = first.pid else {
517            return Err(refuse(None, None, HostIdentityReason::PidUnavailable));
518        };
519
520        let observe = |e: ObserveError| refuse(Some(pid), None, e.into());
521        let changed = |why: &str| {
522            refuse(
523                Some(pid),
524                None,
525                HostIdentityReason::HostChanged(why.to_string()),
526            )
527        };
528
529        let before = witness(stream, pid).map_err(&observe)?;
530        let exe = match os::executable_of(pid) {
531            Ok(Some(exe)) => exe,
532            Ok(None) => return Err(observe(ObserveError::Gone)),
533            Err(e) => {
534                return Err(observe(ObserveError::Unreadable(format!(
535                    "executable path: {e}"
536                ))))
537            }
538        };
539        let after = witness(stream, pid).map_err(&observe)?;
540        if before != after {
541            return Err(changed(
542                "its identity differed before and after its executable path was read",
543            ));
544        }
545        match verify_peer_is_current_user(stream) {
546            Ok(again) if again.pid == Some(pid) => {}
547            _ => return Err(changed("the socket no longer reports the same process id")),
548        }
549
550        let ExeInfo { path, id } = exe;
551        let found = std::fs::canonicalize(&path).unwrap_or(path);
552        if !expect
553            .executables
554            .iter()
555            .any(|expected| same_executable(expected, &found, id))
556        {
557            return Err(refuse(
558                Some(pid),
559                Some(found),
560                HostIdentityReason::ExecutableMismatch,
561            ));
562        }
563
564        let host = HostProcess {
565            pid,
566            uid: Some(current_euid()),
567            sid: None,
568            executable: found,
569            audit_token: after.token,
570        };
571        if let Some(verifier) = &expect.verifier {
572            if let Err(why) = verifier(&host) {
573                return Err(refuse(
574                    Some(pid),
575                    Some(host.executable.clone()),
576                    HostIdentityReason::Refused(why),
577                ));
578            }
579        }
580        Ok(host)
581    }
582
583    #[cfg(target_os = "macos")]
584    mod os {
585        use super::{file_id, missing_or_err, ExeInfo};
586        use std::ffi::{c_void, OsString};
587        use std::io;
588        use std::os::fd::AsRawFd;
589        use std::os::unix::ffi::OsStringExt;
590        use std::os::unix::net::UnixStream;
591        use std::path::PathBuf;
592
593        pub const SUPPORTED: bool = true;
594
595        /// `struct proc_bsdinfo` from `<sys/proc_info.h>`. Checked on macOS (Darwin 27): 136 bytes,
596        /// `pbi_pid` at offset 12, `pbi_start_tvsec` at offset 120.
597        #[repr(C)]
598        #[allow(dead_code)]
599        struct ProcBsdInfo {
600            pbi_flags: u32,
601            pbi_status: u32,
602            pbi_xstatus: u32,
603            pbi_pid: u32,
604            pbi_ppid: u32,
605            pbi_uid: u32,
606            pbi_gid: u32,
607            pbi_ruid: u32,
608            pbi_rgid: u32,
609            pbi_svuid: u32,
610            pbi_svgid: u32,
611            rfu_1: u32,
612            pbi_comm: [u8; 16],
613            pbi_name: [u8; 32],
614            pbi_nfiles: u32,
615            pbi_pgid: u32,
616            pbi_pjobc: u32,
617            e_tdev: u32,
618            e_tpgid: u32,
619            pbi_nice: i32,
620            pbi_start_tvsec: u64,
621            pbi_start_tvusec: u64,
622        }
623        const _: () = assert!(std::mem::size_of::<ProcBsdInfo>() == 136);
624
625        const PROC_PIDTBSDINFO: i32 = 3;
626        const PROC_PIDPATHINFO_MAXSIZE: usize = 4 * 1024;
627        const SOL_LOCAL: i32 = 0;
628        const LOCAL_PEERTOKEN: i32 = 0x006;
629
630        extern "C" {
631            fn proc_pidinfo(
632                pid: i32,
633                flavor: i32,
634                arg: u64,
635                buffer: *mut c_void,
636                buffersize: i32,
637            ) -> i32;
638            fn proc_pidpath(pid: i32, buffer: *mut c_void, buffersize: u32) -> i32;
639            fn getsockopt(fd: i32, level: i32, name: i32, val: *mut c_void, len: *mut u32) -> i32;
640        }
641
642        fn to_pid(pid: u32) -> io::Result<i32> {
643            i32::try_from(pid)
644                .ok()
645                .filter(|p| *p > 0)
646                .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))
647        }
648
649        /// The process's start time (seconds, microseconds) from `proc_pidinfo`. `Ok(None)` when
650        /// there is no such process.
651        pub fn start_marker(pid: u32) -> io::Result<Option<(u64, u64)>> {
652            let raw_pid = to_pid(pid)?;
653            let mut info = std::mem::MaybeUninit::<ProcBsdInfo>::zeroed();
654            let size = std::mem::size_of::<ProcBsdInfo>() as i32;
655            // SAFETY: `info` is a writable buffer of exactly `size` bytes; the call fills it.
656            let written = unsafe {
657                proc_pidinfo(
658                    raw_pid,
659                    PROC_PIDTBSDINFO,
660                    0,
661                    info.as_mut_ptr().cast::<c_void>(),
662                    size,
663                )
664            };
665            if written != size {
666                return missing_or_err();
667            }
668            // SAFETY: the kernel wrote the whole struct (`written == size`), and every field is
669            // a plain integer or byte array, so any bit pattern is valid.
670            let info = unsafe { info.assume_init() };
671            Ok((info.pbi_pid == pid).then_some((info.pbi_start_tvsec, info.pbi_start_tvusec)))
672        }
673
674        /// The process's executable path from `proc_pidpath`. `Ok(None)` when there is no such
675        /// process.
676        pub fn executable_of(pid: u32) -> io::Result<Option<ExeInfo>> {
677            let raw_pid = to_pid(pid)?;
678            let mut buf = vec![0u8; PROC_PIDPATHINFO_MAXSIZE];
679            // SAFETY: `buf` is a writable buffer of the length passed.
680            let n = unsafe {
681                proc_pidpath(raw_pid, buf.as_mut_ptr().cast::<c_void>(), buf.len() as u32)
682            };
683            if n <= 0 {
684                return missing_or_err();
685            }
686            buf.truncate(n as usize);
687            if let Some(nul) = buf.iter().position(|b| *b == 0) {
688                buf.truncate(nul);
689            }
690            let path = PathBuf::from(OsString::from_vec(buf));
691            let id = file_id(&path);
692            Ok(Some(ExeInfo { path, id }))
693        }
694
695        /// The peer's audit token (`LOCAL_PEERTOKEN`), when the kernel provides it.
696        pub fn audit_token(stream: &UnixStream) -> Option<[u32; 8]> {
697            let mut token = [0u32; 8];
698            let mut len = std::mem::size_of_val(&token) as u32;
699            // SAFETY: valid fd borrowed from `stream`; `token` and `len` describe a writable
700            // 32-byte `audit_token_t`.
701            let rc = unsafe {
702                getsockopt(
703                    stream.as_raw_fd(),
704                    SOL_LOCAL,
705                    LOCAL_PEERTOKEN,
706                    token.as_mut_ptr().cast::<c_void>(),
707                    &mut len,
708                )
709            };
710            (rc == 0 && len as usize == std::mem::size_of_val(&token)).then_some(token)
711        }
712    }
713
714    #[cfg(any(target_os = "linux", target_os = "android"))]
715    mod os {
716        use super::{is_no_such_process, ExeInfo};
717        use std::io;
718        use std::os::unix::fs::MetadataExt;
719        use std::os::unix::net::UnixStream;
720
721        pub const SUPPORTED: bool = true;
722
723        /// The process's start time in clock ticks since boot (field 22 of `/proc/<pid>/stat`).
724        /// `Ok(None)` when there is no such process.
725        pub fn start_marker(pid: u32) -> io::Result<Option<(u64, u64)>> {
726            let text = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
727                Ok(text) => text,
728                Err(e) if is_no_such_process(&e) => return Ok(None),
729                Err(e) => return Err(e),
730            };
731            let malformed = || io::Error::new(io::ErrorKind::InvalidData, "unreadable /proc stat");
732            // `pid (comm) state ...`: comm may hold spaces and parentheses, so split after the
733            // last ')'. The state is the first field after it, starttime the 20th.
734            let rest = text.rsplit_once(')').ok_or_else(malformed)?.1;
735            let ticks = rest
736                .split_whitespace()
737                .nth(19)
738                .and_then(|field| field.parse::<u64>().ok())
739                .ok_or_else(malformed)?;
740            Ok(Some((ticks, 0)))
741        }
742
743        /// The process's executable from `/proc/<pid>/exe`; its identity is the file the process
744        /// is really running (the magic link is followed, so a replaced or deleted binary is
745        /// still named exactly). `Ok(None)` when there is no such process.
746        pub fn executable_of(pid: u32) -> io::Result<Option<ExeInfo>> {
747            let link = format!("/proc/{pid}/exe");
748            let path = match std::fs::read_link(&link) {
749                Ok(path) => path,
750                Err(e) if is_no_such_process(&e) => return Ok(None),
751                Err(e) => return Err(e),
752            };
753            let id = std::fs::metadata(&link).ok().map(|md| (md.dev(), md.ino()));
754            Ok(Some(ExeInfo { path, id }))
755        }
756
757        /// Linux has no audit token; the start time is the witness.
758        pub fn audit_token(_stream: &UnixStream) -> Option<[u32; 8]> {
759            None
760        }
761    }
762
763    /// Any other Unix: the executable of a process cannot be read here, so no host is verified.
764    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "android")))]
765    mod os {
766        use super::ExeInfo;
767        use std::io;
768        use std::os::unix::net::UnixStream;
769
770        pub const SUPPORTED: bool = false;
771
772        pub fn start_marker(_pid: u32) -> io::Result<Option<(u64, u64)>> {
773            Err(io::Error::from(io::ErrorKind::Unsupported))
774        }
775        pub fn executable_of(_pid: u32) -> io::Result<Option<ExeInfo>> {
776            Err(io::Error::from(io::ErrorKind::Unsupported))
777        }
778        pub fn audit_token(_stream: &UnixStream) -> Option<[u32; 8]> {
779            None
780        }
781    }
782
783    /// Linux: is this the error of a process that does not exist (`ENOENT` on the `/proc` entry,
784    /// or `ESRCH` when it died mid-read)?
785    #[cfg(any(target_os = "linux", target_os = "android"))]
786    fn is_no_such_process(e: &io::Error) -> bool {
787        e.kind() == io::ErrorKind::NotFound || e.raw_os_error() == Some(3)
788    }
789
790    #[cfg(test)]
791    mod tests {
792        use super::*;
793        use std::os::unix::net::UnixListener;
794
795        fn canonical_current_exe() -> PathBuf {
796            std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap()
797        }
798
799        #[test]
800        fn the_kernel_describes_this_process_and_a_missing_pid_is_gone_not_an_error() {
801            let me = std::process::id();
802            let exe = os::executable_of(me).unwrap().expect("this process exists");
803            assert_eq!(
804                std::fs::canonicalize(&exe.path).unwrap(),
805                canonical_current_exe()
806            );
807            let start = os::start_marker(me).unwrap().expect("this process exists");
808            assert!(start.0 > 0);
809            assert_eq!(os::start_marker(me).unwrap(), Some(start));
810
811            let nobody = i32::MAX as u32;
812            assert!(os::start_marker(nobody).unwrap().is_none());
813            assert!(os::executable_of(nobody).unwrap().is_none());
814        }
815
816        #[test]
817        fn a_listener_in_this_process_is_identified_and_matched_by_its_executable() {
818            let dir = tempfile::tempdir().unwrap();
819            let sock = dir.path().join("h.sock");
820            let _listener = UnixListener::bind(&sock).unwrap();
821            let stream = UnixStream::connect(&sock).unwrap();
822
823            let me = canonical_current_exe();
824            let host = verify_unix_host(&stream, &HostExpectation::executable(&me))
825                .unwrap_or_else(|e| panic!("this binary is the host: {e:?}"));
826            assert_eq!(host.pid, std::process::id());
827            assert_eq!(host.executable, me);
828            assert_eq!(host.uid, Some(current_euid()));
829
830            // Another existing file is not the host: refused with what was found.
831            let other = dir.path().join("other");
832            std::fs::write(&other, b"x").unwrap();
833            let Err(HostConnectError::Identity(e)) =
834                verify_unix_host(&stream, &HostExpectation::executable(&other))
835            else {
836                panic!("a different executable must be refused");
837            };
838            assert_eq!(e.reason, HostIdentityReason::ExecutableMismatch);
839            assert_eq!(e.found_pid, Some(std::process::id()));
840            assert_eq!(e.found_executable, Some(me.clone()));
841            assert_eq!(e.expected, vec![other.clone()]);
842
843            // One matching path among several is enough; a missing one matches nothing.
844            let any = HostExpectation::any_of([other, dir.path().join("missing"), me]);
845            assert!(verify_unix_host(&stream, &any).is_ok());
846        }
847
848        #[test]
849        fn no_expectation_and_a_refusing_verifier_both_fail_closed() {
850            let dir = tempfile::tempdir().unwrap();
851            let sock = dir.path().join("h.sock");
852            let _listener = UnixListener::bind(&sock).unwrap();
853            let stream = UnixStream::connect(&sock).unwrap();
854
855            let Err(HostConnectError::Identity(e)) =
856                verify_unix_host(&stream, &HostExpectation::any_of(Vec::<PathBuf>::new()))
857            else {
858                panic!("an empty expectation must refuse");
859            };
860            assert_eq!(e.reason, HostIdentityReason::NoExpectation);
861
862            let expect = HostExpectation::executable(canonical_current_exe())
863                .with_verifier(|host| Err(format!("pid {} is not signed by us", host.pid)));
864            let Err(HostConnectError::Identity(e)) = verify_unix_host(&stream, &expect) else {
865                panic!("a refusing verifier must refuse");
866            };
867            assert!(
868                matches!(e.reason, HostIdentityReason::Refused(ref why) if why.contains("not signed"))
869            );
870            assert_eq!(e.found_executable, Some(canonical_current_exe()));
871        }
872
873        #[test]
874        fn a_symlink_a_hard_link_and_an_unknown_path_are_judged_by_the_file_not_the_spelling() {
875            let dir = tempfile::tempdir().unwrap();
876            let real = dir.path().join("real");
877            std::fs::write(&real, b"binary").unwrap();
878            let found = std::fs::canonicalize(&real).unwrap();
879            let id = file_id(&found);
880            assert!(id.is_some());
881
882            let link = dir.path().join("link");
883            std::os::unix::fs::symlink(&real, &link).unwrap();
884            assert!(same_executable(&link, &found, id));
885
886            let hard = dir.path().join("hard");
887            std::fs::hard_link(&real, &hard).unwrap();
888            assert!(same_executable(&hard, &found, id), "same file, other name");
889
890            // Same bytes, different file: not the running executable.
891            let copy = dir.path().join("copy");
892            std::fs::copy(&real, &copy).unwrap();
893            assert!(!same_executable(&copy, &found, id));
894
895            assert!(!same_executable(&dir.path().join("missing"), &found, id));
896            // Without a file identity only the canonical path can match.
897            assert!(same_executable(&link, &found, None));
898            assert!(!same_executable(&hard, &found, None));
899        }
900
901        /// macOS volumes are case-insensitive by default and `realpath` keeps the spelling it was
902        /// given, so the same file must still match by identity. Skipped on a case-sensitive
903        /// volume, where the upper-case spelling does not name the file at all.
904        #[cfg(target_os = "macos")]
905        #[test]
906        fn a_case_variant_spelling_of_the_expected_path_names_the_same_file() {
907            let dir = tempfile::tempdir().unwrap();
908            let real = dir.path().join("hostbin");
909            std::fs::write(&real, b"binary").unwrap();
910            let found = std::fs::canonicalize(&real).unwrap();
911            let id = file_id(&found);
912            let upper = dir.path().join("HOSTBIN");
913            if std::fs::metadata(&upper).is_err() {
914                return;
915            }
916            assert!(same_executable(&upper, &found, id));
917        }
918    }
919}
920
921#[cfg(windows)]
922pub(crate) use windows_impl::verify_pipe_host;
923
924/// Windows: which program holds the server end of a named pipe this client opened.
925#[cfg(windows)]
926mod windows_impl {
927    use super::*;
928    use crate::peer::{verify_pipe_server_is_current_user, PeerError};
929    use std::ffi::OsString;
930    use std::os::windows::ffi::OsStringExt;
931    use std::os::windows::io::{AsRawHandle, RawHandle};
932
933    const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
934    const SYNCHRONIZE: u32 = 0x0010_0000;
935    const WAIT_TIMEOUT: u32 = 258;
936    const ERROR_INVALID_PARAMETER: i32 = 87;
937    /// The longest path `QueryFullProcessImageNameW` can return, in UTF-16 units.
938    const LONGEST_PATH: usize = 32_768;
939
940    #[repr(C)]
941    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
942    #[allow(dead_code)]
943    struct FileTime {
944        low: u32,
945        high: u32,
946    }
947
948    /// `BY_HANDLE_FILE_INFORMATION`.
949    #[repr(C)]
950    #[derive(Default)]
951    #[allow(dead_code)]
952    struct ByHandleFileInformation {
953        attributes: u32,
954        creation: FileTime,
955        access: FileTime,
956        write: FileTime,
957        volume_serial: u32,
958        size_high: u32,
959        size_low: u32,
960        links: u32,
961        index_high: u32,
962        index_low: u32,
963    }
964
965    #[link(name = "kernel32")]
966    unsafe extern "system" {
967        fn GetNamedPipeServerProcessId(pipe: RawHandle, pid: *mut u32) -> i32;
968        fn OpenProcess(access: u32, inherit: i32, pid: u32) -> RawHandle;
969        fn GetProcessTimes(
970            process: RawHandle,
971            creation: *mut FileTime,
972            exit: *mut FileTime,
973            kernel: *mut FileTime,
974            user: *mut FileTime,
975        ) -> i32;
976        fn QueryFullProcessImageNameW(
977            process: RawHandle,
978            flags: u32,
979            name: *mut u16,
980            size: *mut u32,
981        ) -> i32;
982        fn WaitForSingleObject(handle: RawHandle, milliseconds: u32) -> u32;
983        fn CloseHandle(handle: RawHandle) -> i32;
984        fn GetFileInformationByHandle(file: RawHandle, info: *mut ByHandleFileInformation) -> i32;
985    }
986
987    /// A process handle. While it is open the process object stays, so its pid is not reused.
988    struct Process(RawHandle);
989
990    impl Drop for Process {
991        fn drop(&mut self) {
992            // SAFETY: the handle came from OpenProcess and is closed once.
993            unsafe { CloseHandle(self.0) };
994        }
995    }
996
997    impl Process {
998        fn open(pid: u32) -> io::Result<Process> {
999            // SAFETY: plain call; a null result is the failure.
1000            let handle =
1001                unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid) };
1002            if handle.is_null() {
1003                return Err(io::Error::last_os_error());
1004            }
1005            Ok(Process(handle))
1006        }
1007
1008        /// When the process was created: with the pid, the identity of one process.
1009        fn created(&self) -> io::Result<FileTime> {
1010            let mut times = [FileTime::default(); 4];
1011            let [creation, exit, kernel, user] = &mut times;
1012            // SAFETY: four valid out-parameters for an open process handle.
1013            if unsafe { GetProcessTimes(self.0, creation, exit, kernel, user) } == 0 {
1014                return Err(io::Error::last_os_error());
1015            }
1016            Ok(times[0])
1017        }
1018
1019        fn running(&self) -> bool {
1020            // SAFETY: the handle was opened with SYNCHRONIZE.
1021            unsafe { WaitForSingleObject(self.0, 0) == WAIT_TIMEOUT }
1022        }
1023
1024        /// The Win32 path of the image the process runs.
1025        fn image(&self) -> io::Result<PathBuf> {
1026            let mut buffer = vec![0u16; LONGEST_PATH];
1027            let mut length = buffer.len() as u32;
1028            // SAFETY: `buffer` holds `length` units; the call writes at most that many and
1029            // sets `length` to the number written, without the terminator.
1030            if unsafe { QueryFullProcessImageNameW(self.0, 0, buffer.as_mut_ptr(), &mut length) }
1031                == 0
1032            {
1033                return Err(io::Error::last_os_error());
1034            }
1035            Ok(PathBuf::from(OsString::from_wide(
1036                &buffer[..length as usize],
1037            )))
1038        }
1039    }
1040
1041    fn server_pid(pipe: RawHandle) -> io::Result<u32> {
1042        let mut pid = 0u32;
1043        // SAFETY: `pipe` is the client end of a named pipe; `pid` is a valid out-parameter.
1044        if unsafe { GetNamedPipeServerProcessId(pipe, &mut pid) } == 0 {
1045            return Err(io::Error::last_os_error());
1046        }
1047        Ok(pid)
1048    }
1049
1050    /// Volume serial number and file index: the same file under any name, as device and inode
1051    /// are on Unix.
1052    pub(super) fn file_id(path: &Path) -> Option<(u32, u64)> {
1053        let file = std::fs::File::open(path).ok()?;
1054        let mut info = ByHandleFileInformation::default();
1055        // SAFETY: an open file handle and a valid out-parameter of the documented layout.
1056        if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
1057            return None;
1058        }
1059        Some((
1060            info.volume_serial,
1061            (u64::from(info.index_high) << 32) | u64::from(info.index_low),
1062        ))
1063    }
1064
1065    /// Whether `expected` names the file at `found` (already canonical): the same canonical path
1066    /// without regard to case (NTFS keeps case and ignores it), or the same file by identity (a
1067    /// hard link).
1068    pub(super) fn same_executable(
1069        expected: &Path,
1070        found: &Path,
1071        found_id: Option<(u32, u64)>,
1072    ) -> bool {
1073        let Ok(expected) = std::fs::canonicalize(expected) else {
1074            return false;
1075        };
1076        let lower = |path: &Path| path.to_string_lossy().to_lowercase();
1077        if lower(&expected) == lower(found) {
1078            return true;
1079        }
1080        found_id.is_some() && file_id(&expected) == found_id
1081    }
1082
1083    /// A canonical path as a person writes it: without the `\\?\` prefix.
1084    fn plain(path: PathBuf) -> PathBuf {
1085        let text = path.to_string_lossy();
1086        match text.strip_prefix(r"\\?\UNC\") {
1087            Some(rest) => PathBuf::from(format!(r"\\{rest}")),
1088            None => match text.strip_prefix(r"\\?\") {
1089                Some(rest) => PathBuf::from(rest),
1090                None => path,
1091            },
1092        }
1093    }
1094
1095    /// Identify the process that holds the server end of `pipe` (the client end, opened with
1096    /// `SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION`) and check it against `expect`, before
1097    /// any byte is written:
1098    ///
1099    /// 1. the pipe's server pid, and that process's token user is this client's (the existing
1100    ///    same-user check, repeated here for its identity);
1101    /// 2. a handle to that process, kept until the end: it pins the process object, so the pid
1102    ///    cannot be given to another process while it is held;
1103    /// 3. the process's creation time, its image path, then the creation time again, that it is
1104    ///    still running, and that the pipe still reports the same pid;
1105    /// 4. the image path, canonicalised, is one of the expected executables (same canonical
1106    ///    path ignoring case, or the same file by volume and index);
1107    /// 5. the caller's extra verifier, if any. A code-signature check (`WinVerifyTrust` on
1108    ///    [`HostProcess::executable`]) belongs there.
1109    pub(crate) fn verify_pipe_host(
1110        pipe: RawHandle,
1111        expect: &HostExpectation,
1112    ) -> Result<HostProcess, HostConnectError> {
1113        let refuse = |pid: Option<u32>, exe: Option<PathBuf>, reason: HostIdentityReason| {
1114            HostConnectError::Identity(HostIdentityError {
1115                expected: expect.executables.clone(),
1116                found_pid: pid,
1117                found_executable: exe,
1118                reason,
1119            })
1120        };
1121        if expect.executables.is_empty() {
1122            return Err(refuse(None, None, HostIdentityReason::NoExpectation));
1123        }
1124
1125        let first = match verify_pipe_server_is_current_user(pipe) {
1126            Ok(id) => id,
1127            Err(e) => {
1128                // The host hung up before it could be identified: a connection problem.
1129                if e.peer_gone() {
1130                    return Err(HostConnectError::Io(io::Error::from(e)));
1131                }
1132                let pid = match &e {
1133                    PeerError::Mismatch(id) => id.pid,
1134                    _ => None,
1135                };
1136                return Err(refuse(pid, None, HostIdentityReason::Query(e.to_string())));
1137            }
1138        };
1139        let Some(pid) = first.pid else {
1140            return Err(refuse(None, None, HostIdentityReason::PidUnavailable));
1141        };
1142        let unreadable = |what: &str, e: io::Error| {
1143            refuse(
1144                Some(pid),
1145                None,
1146                HostIdentityReason::Unreadable(format!("{what}: {e}")),
1147            )
1148        };
1149        let changed = |why: &str| {
1150            refuse(
1151                Some(pid),
1152                None,
1153                HostIdentityReason::HostChanged(why.to_string()),
1154            )
1155        };
1156
1157        let process = match Process::open(pid) {
1158            Ok(process) => process,
1159            // No process has this id any more.
1160            Err(e) if e.raw_os_error() == Some(ERROR_INVALID_PARAMETER) => {
1161                return Err(changed("it exited before it could be opened"))
1162            }
1163            Err(e) => return Err(unreadable("open process", e)),
1164        };
1165        let before = process
1166            .created()
1167            .map_err(|e| unreadable("creation time", e))?;
1168        let image = process
1169            .image()
1170            .map_err(|e| unreadable("executable path", e))?;
1171        let after = process
1172            .created()
1173            .map_err(|e| unreadable("creation time", e))?;
1174        if before != after {
1175            return Err(changed(
1176                "its creation time differed before and after its executable path was read",
1177            ));
1178        }
1179        if !process.running() {
1180            return Err(changed("it exited while it was being identified"));
1181        }
1182        match server_pid(pipe) {
1183            Ok(again) if again == pid => {}
1184            _ => return Err(changed("the pipe no longer reports the same process id")),
1185        }
1186
1187        let found = std::fs::canonicalize(&image).unwrap_or(image);
1188        let id = file_id(&found);
1189        if !expect
1190            .executables
1191            .iter()
1192            .any(|expected| same_executable(expected, &found, id))
1193        {
1194            return Err(refuse(
1195                Some(pid),
1196                Some(plain(found)),
1197                HostIdentityReason::ExecutableMismatch,
1198            ));
1199        }
1200
1201        let host = HostProcess {
1202            pid,
1203            uid: None,
1204            sid: first.sid,
1205            executable: plain(found),
1206            audit_token: None,
1207        };
1208        if let Some(verifier) = &expect.verifier {
1209            if let Err(why) = verifier(&host) {
1210                return Err(refuse(
1211                    Some(pid),
1212                    Some(host.executable.clone()),
1213                    HostIdentityReason::Refused(why),
1214                ));
1215            }
1216        }
1217        // The process handle is released only now: every check above saw one process.
1218        drop(process);
1219        Ok(host)
1220    }
1221
1222    #[cfg(test)]
1223    mod tests {
1224        use super::*;
1225
1226        #[test]
1227        fn the_same_file_matches_by_path_in_any_case_and_by_identity_through_a_hard_link() {
1228            let dir = tempfile::tempdir().unwrap();
1229            let real = dir.path().join("HostBin.exe");
1230            std::fs::write(&real, b"binary").unwrap();
1231            let found = std::fs::canonicalize(&real).unwrap();
1232            let id = file_id(&found);
1233            assert!(id.is_some());
1234
1235            assert!(same_executable(&real, &found, id));
1236            // NTFS ignores case: another spelling names the same file.
1237            assert!(same_executable(&dir.path().join("hostbin.EXE"), &found, id));
1238            assert!(same_executable(
1239                &dir.path().join("hostbin.EXE"),
1240                &found,
1241                None
1242            ));
1243
1244            let hard = dir.path().join("hard.exe");
1245            std::fs::hard_link(&real, &hard).unwrap();
1246            assert!(same_executable(&hard, &found, id), "same file, other name");
1247            // Without a file identity only the canonical path can match.
1248            assert!(!same_executable(&hard, &found, None));
1249
1250            // Same bytes, different file: not the running executable.
1251            let copy = dir.path().join("copy.exe");
1252            std::fs::copy(&real, &copy).unwrap();
1253            assert!(!same_executable(&copy, &found, id));
1254            assert!(!same_executable(
1255                &dir.path().join("missing.exe"),
1256                &found,
1257                id
1258            ));
1259        }
1260
1261        #[test]
1262        fn this_process_is_described_by_its_own_handle() {
1263            let process = Process::open(std::process::id()).unwrap();
1264            assert!(process.running());
1265            assert_eq!(process.created().unwrap(), process.created().unwrap());
1266            let image = std::fs::canonicalize(process.image().unwrap()).unwrap();
1267            let me = std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap();
1268            assert!(same_executable(&me, &image, file_id(&image)));
1269            assert!(!plain(image).to_string_lossy().starts_with(r"\\?\"));
1270        }
1271
1272        #[test]
1273        fn a_process_id_nothing_holds_cannot_be_opened() {
1274            // Process ids are multiples of four; this one is not.
1275            assert!(Process::open(0xFFFF_FFF3).is_err());
1276        }
1277    }
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282    use super::*;
1283
1284    #[test]
1285    fn a_bare_file_name_is_one_component() {
1286        assert!(is_single_component(OsStr::new("mailright")));
1287        assert!(is_single_component(OsStr::new("mailright.exe")));
1288        assert!(!is_single_component(OsStr::new("")));
1289        assert!(!is_single_component(OsStr::new("..")));
1290        assert!(!is_single_component(OsStr::new("a/b")));
1291        assert!(!is_single_component(OsStr::new("/abs")));
1292    }
1293
1294    #[test]
1295    fn install_relative_paths_are_derived_from_where_the_engine_lives() {
1296        let engine = Path::new("/Applications/MailRight.app/Contents/Resources/bin/engine");
1297        assert_eq!(
1298            bundle_main(engine, OsStr::new("mailright")),
1299            Some(PathBuf::from(
1300                "/Applications/MailRight.app/Contents/MacOS/mailright"
1301            ))
1302        );
1303        assert_eq!(
1304            up_and_join(engine, 1, Path::new("app")),
1305            Some(PathBuf::from(
1306                "/Applications/MailRight.app/Contents/Resources/bin/app"
1307            ))
1308        );
1309        assert_eq!(
1310            up_and_join(engine, 3, Path::new("MacOS/mailright")),
1311            Some(PathBuf::from(
1312                "/Applications/MailRight.app/Contents/MacOS/mailright"
1313            ))
1314        );
1315        // Not in a bundle; level 0 is the executable itself; absolute relative paths are refused.
1316        assert_eq!(
1317            bundle_main(Path::new("/opt/app/bin/engine"), OsStr::new("x")),
1318            None
1319        );
1320        assert_eq!(up_and_join(engine, 0, Path::new("x")), None);
1321        assert_eq!(up_and_join(engine, 1, Path::new("/etc/passwd")), None);
1322        // Windows: a rooted path without a drive, and a drive-relative one, are not relative.
1323        #[cfg(windows)]
1324        for escape in [r"\Windows\System32\cmd.exe", "C:cmd.exe", r"C:\cmd.exe"] {
1325            assert_eq!(up_and_join(engine, 1, Path::new(escape)), None, "{escape}");
1326        }
1327        assert_eq!(up_and_join(Path::new("engine"), 5, Path::new("x")), None);
1328    }
1329
1330    #[test]
1331    fn helpers_resolve_against_the_running_test_binary() {
1332        let exe = std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap();
1333        let sibling = HostExpectation::sibling_executable("host-binary").unwrap();
1334        assert_eq!(
1335            sibling.executables(),
1336            [exe.parent().unwrap().join("host-binary")]
1337        );
1338        assert!(HostExpectation::sibling_executable("a/b").is_err());
1339        assert!(HostExpectation::sibling_executable("..").is_err());
1340        assert!(HostExpectation::bundle_executable("x/y").is_err());
1341        let up2 = HostExpectation::relative_to_current_exe(2, "bin/host").unwrap();
1342        assert_eq!(
1343            up2.executables(),
1344            [exe.parent().unwrap().parent().unwrap().join("bin/host")]
1345        );
1346        assert!(HostExpectation::relative_to_current_exe(0, "x").is_err());
1347    }
1348
1349    #[test]
1350    fn the_error_says_what_was_expected_and_what_was_found() {
1351        let e = HostIdentityError {
1352            expected: vec![PathBuf::from("/a/app"), PathBuf::from("/b/app")],
1353            found_pid: Some(42),
1354            found_executable: Some(PathBuf::from("/tmp/evil")),
1355            reason: HostIdentityReason::ExecutableMismatch,
1356        };
1357        let text = e.to_string();
1358        assert!(
1359            text.contains("pid 42") && text.contains("/tmp/evil"),
1360            "{text}"
1361        );
1362        assert!(text.contains("/a/app or /b/app"), "{text}");
1363        let none = HostIdentityError {
1364            expected: Vec::new(),
1365            found_pid: None,
1366            found_executable: None,
1367            reason: HostIdentityReason::NoExpectation,
1368        };
1369        assert!(none.to_string().contains("none supplied"));
1370    }
1371
1372    #[test]
1373    fn a_refusal_collapses_to_a_non_retryable_wire_error() {
1374        let e = HostIdentityError {
1375            expected: vec![PathBuf::from("/a/app")],
1376            found_pid: Some(7),
1377            found_executable: None,
1378            reason: HostIdentityReason::PidUnavailable,
1379        };
1380        let wire: crate::ServiceError = e.clone().into();
1381        assert_eq!(wire.code, crate::ERROR_HOST_IDENTITY);
1382        assert!(!wire.retryable);
1383        assert!(wire.message.contains("pid 7"));
1384        let c: crate::ConnectError = e.into();
1385        assert_eq!(c.into_service_error(), wire);
1386    }
1387}