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