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::{verify_unix_host, verify_unix_pid};
422// Read only by peer.rs's getpeereid branch (the BSDs and Apple platforms).
423#[cfg(all(
424    unix,
425    any(
426        target_os = "macos",
427        target_os = "ios",
428        target_os = "freebsd",
429        target_os = "openbsd",
430        target_os = "netbsd",
431        target_os = "dragonfly"
432    )
433))]
434pub(crate) use unix_impl::peer_audit_token;
435
436#[cfg(unix)]
437mod unix_impl {
438    use super::{
439        HostConnectError, HostExpectation, HostIdentityError, HostIdentityReason, HostProcess,
440    };
441    use crate::peer::{current_euid, verify_peer_is_current_user, PeerError, PeerIdentity};
442    use std::io;
443    use std::os::unix::fs::MetadataExt;
444    use std::os::unix::net::UnixStream;
445    use std::path::{Path, PathBuf};
446
447    /// `(device, inode)`: names one file whatever path it is reached by.
448    pub type FileId = (u64, u64);
449
450    pub fn file_id(path: &Path) -> Option<FileId> {
451        let md = std::fs::metadata(path).ok()?;
452        Some((md.dev(), md.ino()))
453    }
454
455    /// A running process's executable as the kernel reports it.
456    pub struct ExeInfo {
457        pub path: PathBuf,
458        /// The file the process is actually running, when the kernel lets us stat it (Linux:
459        /// through `/proc/<pid>/exe`, exact even if the file was replaced; macOS: by path).
460        pub id: Option<FileId>,
461    }
462
463    /// What must be unchanged across the executable-path read to prove the pid named one process
464    /// throughout: its start time, and (macOS) its audit token, which carries a pid version.
465    #[derive(PartialEq, Eq)]
466    struct Witness {
467        start: (u64, u64),
468        token: Option<[u32; 8]>,
469    }
470
471    enum ObserveError {
472        Gone,
473        Unreadable(String),
474    }
475
476    impl From<ObserveError> for HostIdentityReason {
477        fn from(e: ObserveError) -> Self {
478            match e {
479                ObserveError::Gone => HostIdentityReason::HostChanged(
480                    "it exited before it could be identified".to_string(),
481                ),
482                ObserveError::Unreadable(why) => HostIdentityReason::Unreadable(why),
483            }
484        }
485    }
486
487    /// macOS: the errno that says "no such process" (ESRCH, 3), or a stale zero, is `Ok(None)`;
488    /// anything else is an error. Read right after a `proc_*` call that returned 0.
489    #[cfg(target_os = "macos")]
490    fn missing_or_err<T>() -> io::Result<Option<T>> {
491        let e = io::Error::last_os_error();
492        match e.raw_os_error() {
493            Some(3) | Some(0) | None => Ok(None),
494            _ => Err(e),
495        }
496    }
497
498    fn witness(stream: &UnixStream, pid: u32) -> Result<Witness, ObserveError> {
499        let token = os::audit_token(stream);
500        let start = match os::start_marker(pid) {
501            Ok(Some(start)) => start,
502            Ok(None) => return Err(ObserveError::Gone),
503            Err(e) => return Err(ObserveError::Unreadable(format!("process start time: {e}"))),
504        };
505        Ok(Witness { start, token })
506    }
507
508    /// Is the running executable (`found`, canonical, with its file identity `found_id`) the one
509    /// `expected` names? Equal after canonicalisation, or the very same file (device and inode),
510    /// which covers a case-insensitive volume (macOS `realpath` does not normalise case, so
511    /// `/applications/x` and `/Applications/x` differ as strings) and a hard link. An expected
512    /// path that does not exist matches nothing.
513    pub fn same_executable(expected: &Path, found: &Path, found_id: Option<FileId>) -> bool {
514        let Ok(canonical) = std::fs::canonicalize(expected) else {
515            return false;
516        };
517        if canonical == found {
518            return true;
519        }
520        match (found_id, file_id(&canonical)) {
521            (Some(running), Some(named)) => running == named,
522            _ => false,
523        }
524    }
525
526    /// Ask the kernel who is on the listening end of `stream` and decide whether it is the host
527    /// `expect` names. Reads and writes nothing on the stream.
528    ///
529    /// Order, each step failing closed:
530    /// 1. the peer's uid equals ours (`getpeereid` / `SO_PEERCRED`, the existing same-user check);
531    /// 2. the pid the kernel reports for the socket (`LOCAL_PEERPID` / `SO_PEERCRED`);
532    /// 3. the pid's start time (and, on macOS, the audit token), then its executable path
533    ///    (`proc_pidpath` / `/proc/<pid>/exe`), then the start time and token again, then the pid
534    ///    from the socket again: all must agree, or the pid cannot be proven to name one process;
535    /// 4. the executable is one of the expected executables;
536    /// 5. the caller's extra verifier, if any.
537    pub(crate) fn verify_unix_host(
538        stream: &UnixStream,
539        expect: &HostExpectation,
540    ) -> Result<HostProcess, HostConnectError> {
541        let refuse = |pid: Option<u32>, exe: Option<PathBuf>, reason: HostIdentityReason| {
542            HostConnectError::Identity(HostIdentityError {
543                expected: expect.executables.clone(),
544                found_pid: pid,
545                found_executable: exe,
546                reason,
547            })
548        };
549        if expect.executables.is_empty() {
550            return Err(refuse(None, None, HostIdentityReason::NoExpectation));
551        }
552        if !os::SUPPORTED {
553            return Err(refuse(
554                None,
555                None,
556                HostIdentityReason::Unverified {
557                    platform: std::env::consts::OS,
558                    detail: "reading a process's executable path is not implemented for this Unix",
559                },
560            ));
561        }
562
563        let first = match verify_peer_is_current_user(stream) {
564            Ok(id) => id,
565            Err(PeerError::Mismatch(id)) => {
566                return Err(refuse(
567                    id.pid,
568                    None,
569                    HostIdentityReason::UidMismatch {
570                        host: id.uid,
571                        client: current_euid(),
572                    },
573                ))
574            }
575            Err(PeerError::Unsupported(detail)) => {
576                return Err(refuse(
577                    None,
578                    None,
579                    HostIdentityReason::Unverified {
580                        platform: std::env::consts::OS,
581                        detail,
582                    },
583                ))
584            }
585            Err(e) => {
586                // The host hung up before it could be identified: a connection problem.
587                if e.peer_gone() {
588                    return Err(HostConnectError::Io(io::Error::from(e)));
589                }
590                return Err(refuse(None, None, HostIdentityReason::Query(e.to_string())));
591            }
592        };
593        let Some(pid) = first.pid else {
594            return Err(refuse(None, None, HostIdentityReason::PidUnavailable));
595        };
596
597        let observe = |e: ObserveError| refuse(Some(pid), None, e.into());
598        let changed = |why: &str| {
599            refuse(
600                Some(pid),
601                None,
602                HostIdentityReason::HostChanged(why.to_string()),
603            )
604        };
605
606        let before = witness(stream, pid).map_err(&observe)?;
607        let exe = match os::executable_of(pid) {
608            Ok(Some(exe)) => exe,
609            Ok(None) => return Err(observe(ObserveError::Gone)),
610            Err(e) => {
611                return Err(observe(ObserveError::Unreadable(format!(
612                    "executable path: {e}"
613                ))))
614            }
615        };
616        let after = witness(stream, pid).map_err(&observe)?;
617        if before != after {
618            return Err(changed(
619                "its identity differed before and after its executable path was read",
620            ));
621        }
622        match verify_peer_is_current_user(stream) {
623            Ok(again) if again.pid == Some(pid) => {}
624            _ => return Err(changed("the socket no longer reports the same process id")),
625        }
626
627        conclude(expect, pid, exe, after.token).map_err(HostConnectError::Identity)
628    }
629
630    /// The last steps, shared by the client-side and the server-side check: the process's
631    /// executable (canonicalised) must be one of the expected executables, then the caller's
632    /// extra verifier, if any, must accept the process.
633    fn conclude(
634        expect: &HostExpectation,
635        pid: u32,
636        exe: ExeInfo,
637        audit_token: Option<[u32; 8]>,
638    ) -> Result<HostProcess, HostIdentityError> {
639        let refuse = |exe: Option<PathBuf>, reason: HostIdentityReason| HostIdentityError {
640            expected: expect.executables.clone(),
641            found_pid: Some(pid),
642            found_executable: exe,
643            reason,
644        };
645        let ExeInfo { path, id } = exe;
646        let found = std::fs::canonicalize(&path).unwrap_or(path);
647        if !expect
648            .executables
649            .iter()
650            .any(|expected| same_executable(expected, &found, id))
651        {
652            return Err(refuse(Some(found), HostIdentityReason::ExecutableMismatch));
653        }
654
655        let host = HostProcess {
656            pid,
657            uid: Some(current_euid()),
658            sid: None,
659            executable: found,
660            audit_token,
661        };
662        if let Some(verifier) = &expect.verifier {
663            if let Err(why) = verifier(&host) {
664                return Err(refuse(
665                    Some(host.executable.clone()),
666                    HostIdentityReason::Refused(why),
667                ));
668            }
669        }
670        Ok(host)
671    }
672
673    /// Server side: identify the process that opened a connection, from the OS-verified
674    /// [`PeerIdentity`] recorded when it connected, and decide whether it is the program
675    /// `expect` names. Same decision as [`verify_unix_host`] (same executable comparison, same
676    /// verifier), but the socket is no longer at hand, so the process is pinned by its pid, its
677    /// start time read before and after its executable path, and (macOS) the audit token the
678    /// kernel captured at connect time.
679    ///
680    /// Each step fails closed: no uid or a different uid, no pid, a process that is gone, a
681    /// start time that changed, an audit token that names another pid, an unreadable executable,
682    /// no expected executable, an executable that is not expected, a refusing verifier.
683    pub(crate) fn verify_unix_pid(
684        peer: &PeerIdentity,
685        expect: &HostExpectation,
686    ) -> Result<HostProcess, HostIdentityError> {
687        let refuse = |pid: Option<u32>, exe: Option<PathBuf>, reason: HostIdentityReason| {
688            HostIdentityError {
689                expected: expect.executables.clone(),
690                found_pid: pid,
691                found_executable: exe,
692                reason,
693            }
694        };
695        if expect.executables.is_empty() {
696            return Err(refuse(None, None, HostIdentityReason::NoExpectation));
697        }
698        if !os::SUPPORTED {
699            return Err(refuse(
700                None,
701                None,
702                HostIdentityReason::Unverified {
703                    platform: std::env::consts::OS,
704                    detail: "reading a process's executable path is not implemented for this Unix",
705                },
706            ));
707        }
708        if peer.uid != Some(current_euid()) {
709            return Err(refuse(
710                peer.pid,
711                None,
712                HostIdentityReason::UidMismatch {
713                    host: peer.uid,
714                    client: current_euid(),
715                },
716            ));
717        }
718        let Some(pid) = peer.pid else {
719            return Err(refuse(None, None, HostIdentityReason::PidUnavailable));
720        };
721        if let Some(token) = peer.audit_token {
722            // `audit_token_t`: the pid is word 5.
723            if token[5] != pid {
724                return Err(refuse(
725                    Some(pid),
726                    None,
727                    HostIdentityReason::HostChanged(
728                        "the audit token and the process id name different processes".to_string(),
729                    ),
730                ));
731            }
732        }
733        let observe = |e: ObserveError| refuse(Some(pid), None, e.into());
734        let start = |pid| match os::start_marker(pid) {
735            Ok(Some(start)) => Ok(start),
736            Ok(None) => Err(ObserveError::Gone),
737            Err(e) => Err(ObserveError::Unreadable(format!("process start time: {e}"))),
738        };
739        let before = start(pid).map_err(&observe)?;
740        // The pid must still be the process that connected: its start time was recorded when
741        // the connection was accepted. No record, or a different one, is a pid that may have
742        // been reused. Fails closed.
743        if peer.started != Some(before) {
744            return Err(refuse(
745                Some(pid),
746                None,
747                HostIdentityReason::HostChanged(
748                    concat!(
749                        "the process is not the one that connected (its start time differs ",
750                        "from the one recorded at connect, or none was recorded)"
751                    )
752                    .to_string(),
753                ),
754            ));
755        }
756        let exe = match os::executable_of(pid) {
757            Ok(Some(exe)) => exe,
758            Ok(None) => return Err(observe(ObserveError::Gone)),
759            Err(e) => {
760                return Err(observe(ObserveError::Unreadable(format!(
761                    "executable path: {e}"
762                ))))
763            }
764        };
765        let after = start(pid).map_err(&observe)?;
766        if before != after {
767            return Err(refuse(
768                Some(pid),
769                None,
770                HostIdentityReason::HostChanged(
771                    "its start time differed before and after its executable path was read"
772                        .to_string(),
773                ),
774            ));
775        }
776        conclude(expect, pid, exe, peer.audit_token)
777    }
778
779    pub(crate) fn start_marker_of(pid: u32) -> Option<(u64, u64)> {
780        os::start_marker(pid).ok().flatten()
781    }
782
783    /// The peer's audit token for [`PeerIdentity::audit_token`]; `None` off macOS.
784    #[cfg(any(
785        target_os = "macos",
786        target_os = "ios",
787        target_os = "freebsd",
788        target_os = "openbsd",
789        target_os = "netbsd",
790        target_os = "dragonfly"
791    ))]
792    pub(crate) fn peer_audit_token(stream: &UnixStream) -> Option<[u32; 8]> {
793        os::audit_token(stream)
794    }
795
796    #[cfg(target_os = "macos")]
797    mod os {
798        use super::{file_id, missing_or_err, ExeInfo};
799        use std::ffi::{c_void, OsString};
800        use std::io;
801        use std::os::fd::AsRawFd;
802        use std::os::unix::ffi::OsStringExt;
803        use std::os::unix::net::UnixStream;
804        use std::path::PathBuf;
805
806        pub const SUPPORTED: bool = true;
807
808        /// `struct proc_bsdinfo` from `<sys/proc_info.h>`. Checked on macOS (Darwin 27): 136 bytes,
809        /// `pbi_pid` at offset 12, `pbi_start_tvsec` at offset 120.
810        #[repr(C)]
811        #[allow(dead_code)]
812        struct ProcBsdInfo {
813            pbi_flags: u32,
814            pbi_status: u32,
815            pbi_xstatus: u32,
816            pbi_pid: u32,
817            pbi_ppid: u32,
818            pbi_uid: u32,
819            pbi_gid: u32,
820            pbi_ruid: u32,
821            pbi_rgid: u32,
822            pbi_svuid: u32,
823            pbi_svgid: u32,
824            rfu_1: u32,
825            pbi_comm: [u8; 16],
826            pbi_name: [u8; 32],
827            pbi_nfiles: u32,
828            pbi_pgid: u32,
829            pbi_pjobc: u32,
830            e_tdev: u32,
831            e_tpgid: u32,
832            pbi_nice: i32,
833            pbi_start_tvsec: u64,
834            pbi_start_tvusec: u64,
835        }
836        const _: () = assert!(std::mem::size_of::<ProcBsdInfo>() == 136);
837
838        const PROC_PIDTBSDINFO: i32 = 3;
839        const PROC_PIDPATHINFO_MAXSIZE: usize = 4 * 1024;
840        const SOL_LOCAL: i32 = 0;
841        const LOCAL_PEERTOKEN: i32 = 0x006;
842
843        extern "C" {
844            fn proc_pidinfo(
845                pid: i32,
846                flavor: i32,
847                arg: u64,
848                buffer: *mut c_void,
849                buffersize: i32,
850            ) -> i32;
851            fn proc_pidpath(pid: i32, buffer: *mut c_void, buffersize: u32) -> i32;
852            fn getsockopt(fd: i32, level: i32, name: i32, val: *mut c_void, len: *mut u32) -> i32;
853        }
854
855        fn to_pid(pid: u32) -> io::Result<i32> {
856            i32::try_from(pid)
857                .ok()
858                .filter(|p| *p > 0)
859                .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))
860        }
861
862        /// The process's start time (seconds, microseconds) from `proc_pidinfo`. `Ok(None)` when
863        /// there is no such process.
864        pub fn start_marker(pid: u32) -> io::Result<Option<(u64, u64)>> {
865            let raw_pid = to_pid(pid)?;
866            let mut info = std::mem::MaybeUninit::<ProcBsdInfo>::zeroed();
867            let size = std::mem::size_of::<ProcBsdInfo>() as i32;
868            // SAFETY: `info` is a writable buffer of exactly `size` bytes; the call fills it.
869            let written = unsafe {
870                proc_pidinfo(
871                    raw_pid,
872                    PROC_PIDTBSDINFO,
873                    0,
874                    info.as_mut_ptr().cast::<c_void>(),
875                    size,
876                )
877            };
878            if written != size {
879                return missing_or_err();
880            }
881            // SAFETY: the kernel wrote the whole struct (`written == size`), and every field is
882            // a plain integer or byte array, so any bit pattern is valid.
883            let info = unsafe { info.assume_init() };
884            Ok((info.pbi_pid == pid).then_some((info.pbi_start_tvsec, info.pbi_start_tvusec)))
885        }
886
887        /// The process's executable path from `proc_pidpath`. `Ok(None)` when there is no such
888        /// process.
889        pub fn executable_of(pid: u32) -> io::Result<Option<ExeInfo>> {
890            let raw_pid = to_pid(pid)?;
891            let mut buf = vec![0u8; PROC_PIDPATHINFO_MAXSIZE];
892            // SAFETY: `buf` is a writable buffer of the length passed.
893            let n = unsafe {
894                proc_pidpath(raw_pid, buf.as_mut_ptr().cast::<c_void>(), buf.len() as u32)
895            };
896            if n <= 0 {
897                return missing_or_err();
898            }
899            buf.truncate(n as usize);
900            if let Some(nul) = buf.iter().position(|b| *b == 0) {
901                buf.truncate(nul);
902            }
903            let path = PathBuf::from(OsString::from_vec(buf));
904            let id = file_id(&path);
905            Ok(Some(ExeInfo { path, id }))
906        }
907
908        /// The peer's audit token (`LOCAL_PEERTOKEN`), when the kernel provides it.
909        pub fn audit_token(stream: &UnixStream) -> Option<[u32; 8]> {
910            let mut token = [0u32; 8];
911            let mut len = std::mem::size_of_val(&token) as u32;
912            // SAFETY: valid fd borrowed from `stream`; `token` and `len` describe a writable
913            // 32-byte `audit_token_t`.
914            let rc = unsafe {
915                getsockopt(
916                    stream.as_raw_fd(),
917                    SOL_LOCAL,
918                    LOCAL_PEERTOKEN,
919                    token.as_mut_ptr().cast::<c_void>(),
920                    &mut len,
921                )
922            };
923            (rc == 0 && len as usize == std::mem::size_of_val(&token)).then_some(token)
924        }
925    }
926
927    #[cfg(any(target_os = "linux", target_os = "android"))]
928    mod os {
929        use super::{is_no_such_process, ExeInfo};
930        use std::io;
931        use std::os::unix::fs::MetadataExt;
932        use std::os::unix::net::UnixStream;
933
934        pub const SUPPORTED: bool = true;
935
936        /// The process's start time in clock ticks since boot (field 22 of `/proc/<pid>/stat`).
937        /// `Ok(None)` when there is no such process.
938        pub fn start_marker(pid: u32) -> io::Result<Option<(u64, u64)>> {
939            let text = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
940                Ok(text) => text,
941                Err(e) if is_no_such_process(&e) => return Ok(None),
942                Err(e) => return Err(e),
943            };
944            let malformed = || io::Error::new(io::ErrorKind::InvalidData, "unreadable /proc stat");
945            // `pid (comm) state ...`: comm may hold spaces and parentheses, so split after the
946            // last ')'. The state is the first field after it, starttime the 20th.
947            let rest = text.rsplit_once(')').ok_or_else(malformed)?.1;
948            let ticks = rest
949                .split_whitespace()
950                .nth(19)
951                .and_then(|field| field.parse::<u64>().ok())
952                .ok_or_else(malformed)?;
953            Ok(Some((ticks, 0)))
954        }
955
956        /// The process's executable from `/proc/<pid>/exe`; its identity is the file the process
957        /// is really running (the magic link is followed, so a replaced or deleted binary is
958        /// still named exactly). `Ok(None)` when there is no such process.
959        pub fn executable_of(pid: u32) -> io::Result<Option<ExeInfo>> {
960            let link = format!("/proc/{pid}/exe");
961            let path = match std::fs::read_link(&link) {
962                Ok(path) => path,
963                Err(e) if is_no_such_process(&e) => return Ok(None),
964                Err(e) => return Err(e),
965            };
966            let id = std::fs::metadata(&link).ok().map(|md| (md.dev(), md.ino()));
967            Ok(Some(ExeInfo { path, id }))
968        }
969
970        /// Linux has no audit token; the start time is the witness.
971        pub fn audit_token(_stream: &UnixStream) -> Option<[u32; 8]> {
972            None
973        }
974    }
975
976    /// Any other Unix: the executable of a process cannot be read here, so no host is verified.
977    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "android")))]
978    mod os {
979        use super::ExeInfo;
980        use std::io;
981        use std::os::unix::net::UnixStream;
982
983        pub const SUPPORTED: bool = false;
984
985        pub fn start_marker(_pid: u32) -> io::Result<Option<(u64, u64)>> {
986            Err(io::Error::from(io::ErrorKind::Unsupported))
987        }
988        pub fn executable_of(_pid: u32) -> io::Result<Option<ExeInfo>> {
989            Err(io::Error::from(io::ErrorKind::Unsupported))
990        }
991        pub fn audit_token(_stream: &UnixStream) -> Option<[u32; 8]> {
992            None
993        }
994    }
995
996    /// Linux: is this the error of a process that does not exist (`ENOENT` on the `/proc` entry,
997    /// or `ESRCH` when it died mid-read)?
998    #[cfg(any(target_os = "linux", target_os = "android"))]
999    fn is_no_such_process(e: &io::Error) -> bool {
1000        e.kind() == io::ErrorKind::NotFound || e.raw_os_error() == Some(3)
1001    }
1002
1003    #[cfg(test)]
1004    mod tests {
1005        use super::*;
1006        use std::os::unix::net::UnixListener;
1007
1008        fn canonical_current_exe() -> PathBuf {
1009            std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap()
1010        }
1011
1012        #[test]
1013        fn the_kernel_describes_this_process_and_a_missing_pid_is_gone_not_an_error() {
1014            let me = std::process::id();
1015            let exe = os::executable_of(me).unwrap().expect("this process exists");
1016            assert_eq!(
1017                std::fs::canonicalize(&exe.path).unwrap(),
1018                canonical_current_exe()
1019            );
1020            let start = os::start_marker(me).unwrap().expect("this process exists");
1021            assert!(start.0 > 0);
1022            assert_eq!(os::start_marker(me).unwrap(), Some(start));
1023
1024            let nobody = i32::MAX as u32;
1025            assert!(os::start_marker(nobody).unwrap().is_none());
1026            assert!(os::executable_of(nobody).unwrap().is_none());
1027        }
1028
1029        #[test]
1030        fn a_listener_in_this_process_is_identified_and_matched_by_its_executable() {
1031            let dir = tempfile::tempdir().unwrap();
1032            let sock = dir.path().join("h.sock");
1033            let _listener = UnixListener::bind(&sock).unwrap();
1034            let stream = UnixStream::connect(&sock).unwrap();
1035
1036            let me = canonical_current_exe();
1037            let host = verify_unix_host(&stream, &HostExpectation::executable(&me))
1038                .unwrap_or_else(|e| panic!("this binary is the host: {e:?}"));
1039            assert_eq!(host.pid, std::process::id());
1040            assert_eq!(host.executable, me);
1041            assert_eq!(host.uid, Some(current_euid()));
1042
1043            // Another existing file is not the host: refused with what was found.
1044            let other = dir.path().join("other");
1045            std::fs::write(&other, b"x").unwrap();
1046            let Err(HostConnectError::Identity(e)) =
1047                verify_unix_host(&stream, &HostExpectation::executable(&other))
1048            else {
1049                panic!("a different executable must be refused");
1050            };
1051            assert_eq!(e.reason, HostIdentityReason::ExecutableMismatch);
1052            assert_eq!(e.found_pid, Some(std::process::id()));
1053            assert_eq!(e.found_executable, Some(me.clone()));
1054            assert_eq!(e.expected, vec![other.clone()]);
1055
1056            // One matching path among several is enough; a missing one matches nothing.
1057            let any = HostExpectation::any_of([other, dir.path().join("missing"), me]);
1058            assert!(verify_unix_host(&stream, &any).is_ok());
1059        }
1060
1061        #[test]
1062        fn no_expectation_and_a_refusing_verifier_both_fail_closed() {
1063            let dir = tempfile::tempdir().unwrap();
1064            let sock = dir.path().join("h.sock");
1065            let _listener = UnixListener::bind(&sock).unwrap();
1066            let stream = UnixStream::connect(&sock).unwrap();
1067
1068            let Err(HostConnectError::Identity(e)) =
1069                verify_unix_host(&stream, &HostExpectation::any_of(Vec::<PathBuf>::new()))
1070            else {
1071                panic!("an empty expectation must refuse");
1072            };
1073            assert_eq!(e.reason, HostIdentityReason::NoExpectation);
1074
1075            let expect = HostExpectation::executable(canonical_current_exe())
1076                .with_verifier(|host| Err(format!("pid {} is not signed by us", host.pid)));
1077            let Err(HostConnectError::Identity(e)) = verify_unix_host(&stream, &expect) else {
1078                panic!("a refusing verifier must refuse");
1079            };
1080            assert!(
1081                matches!(e.reason, HostIdentityReason::Refused(ref why) if why.contains("not signed"))
1082            );
1083            assert_eq!(e.found_executable, Some(canonical_current_exe()));
1084        }
1085
1086        #[test]
1087        fn a_symlink_a_hard_link_and_an_unknown_path_are_judged_by_the_file_not_the_spelling() {
1088            let dir = tempfile::tempdir().unwrap();
1089            let real = dir.path().join("real");
1090            std::fs::write(&real, b"binary").unwrap();
1091            let found = std::fs::canonicalize(&real).unwrap();
1092            let id = file_id(&found);
1093            assert!(id.is_some());
1094
1095            let link = dir.path().join("link");
1096            std::os::unix::fs::symlink(&real, &link).unwrap();
1097            assert!(same_executable(&link, &found, id));
1098
1099            let hard = dir.path().join("hard");
1100            std::fs::hard_link(&real, &hard).unwrap();
1101            assert!(same_executable(&hard, &found, id), "same file, other name");
1102
1103            // Same bytes, different file: not the running executable.
1104            let copy = dir.path().join("copy");
1105            std::fs::copy(&real, &copy).unwrap();
1106            assert!(!same_executable(&copy, &found, id));
1107
1108            assert!(!same_executable(&dir.path().join("missing"), &found, id));
1109            // Without a file identity only the canonical path can match.
1110            assert!(same_executable(&link, &found, None));
1111            assert!(!same_executable(&hard, &found, None));
1112        }
1113
1114        /// macOS volumes are case-insensitive by default and `realpath` keeps the spelling it was
1115        /// given, so the same file must still match by identity. Skipped on a case-sensitive
1116        /// volume, where the upper-case spelling does not name the file at all.
1117        #[cfg(target_os = "macos")]
1118        #[test]
1119        fn a_case_variant_spelling_of_the_expected_path_names_the_same_file() {
1120            let dir = tempfile::tempdir().unwrap();
1121            let real = dir.path().join("hostbin");
1122            std::fs::write(&real, b"binary").unwrap();
1123            let found = std::fs::canonicalize(&real).unwrap();
1124            let id = file_id(&found);
1125            let upper = dir.path().join("HOSTBIN");
1126            if std::fs::metadata(&upper).is_err() {
1127                return;
1128            }
1129            assert!(same_executable(&upper, &found, id));
1130        }
1131    }
1132}
1133
1134#[cfg(windows)]
1135pub(crate) use windows_impl::{verify_pid_host, verify_pipe_host};
1136
1137/// Windows: which program holds the server end of a named pipe this client opened.
1138#[cfg(windows)]
1139mod windows_impl {
1140    use super::*;
1141    use crate::peer::{verify_pipe_server_is_current_user, PeerError, PeerIdentity};
1142    use std::ffi::OsString;
1143    use std::os::windows::ffi::OsStringExt;
1144    use std::os::windows::io::{AsRawHandle, RawHandle};
1145
1146    const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
1147    const SYNCHRONIZE: u32 = 0x0010_0000;
1148    const WAIT_TIMEOUT: u32 = 258;
1149    const ERROR_INVALID_PARAMETER: i32 = 87;
1150    /// The longest path `QueryFullProcessImageNameW` can return, in UTF-16 units.
1151    const LONGEST_PATH: usize = 32_768;
1152
1153    #[repr(C)]
1154    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1155    #[allow(dead_code)]
1156    struct FileTime {
1157        low: u32,
1158        high: u32,
1159    }
1160
1161    /// `BY_HANDLE_FILE_INFORMATION`.
1162    #[repr(C)]
1163    #[derive(Default)]
1164    #[allow(dead_code)]
1165    struct ByHandleFileInformation {
1166        attributes: u32,
1167        creation: FileTime,
1168        access: FileTime,
1169        write: FileTime,
1170        volume_serial: u32,
1171        size_high: u32,
1172        size_low: u32,
1173        links: u32,
1174        index_high: u32,
1175        index_low: u32,
1176    }
1177
1178    #[link(name = "kernel32")]
1179    unsafe extern "system" {
1180        fn GetNamedPipeServerProcessId(pipe: RawHandle, pid: *mut u32) -> i32;
1181        fn OpenProcess(access: u32, inherit: i32, pid: u32) -> RawHandle;
1182        fn GetProcessTimes(
1183            process: RawHandle,
1184            creation: *mut FileTime,
1185            exit: *mut FileTime,
1186            kernel: *mut FileTime,
1187            user: *mut FileTime,
1188        ) -> i32;
1189        fn QueryFullProcessImageNameW(
1190            process: RawHandle,
1191            flags: u32,
1192            name: *mut u16,
1193            size: *mut u32,
1194        ) -> i32;
1195        fn WaitForSingleObject(handle: RawHandle, milliseconds: u32) -> u32;
1196        fn CloseHandle(handle: RawHandle) -> i32;
1197        fn GetFileInformationByHandle(file: RawHandle, info: *mut ByHandleFileInformation) -> i32;
1198    }
1199
1200    /// A process handle. While it is open the process object stays, so its pid is not reused.
1201    struct Process(RawHandle);
1202
1203    impl Drop for Process {
1204        fn drop(&mut self) {
1205            // SAFETY: the handle came from OpenProcess and is closed once.
1206            unsafe { CloseHandle(self.0) };
1207        }
1208    }
1209
1210    impl Process {
1211        fn open(pid: u32) -> io::Result<Process> {
1212            // SAFETY: plain call; a null result is the failure.
1213            let handle =
1214                unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid) };
1215            if handle.is_null() {
1216                return Err(io::Error::last_os_error());
1217            }
1218            Ok(Process(handle))
1219        }
1220
1221        /// When the process was created: with the pid, the identity of one process.
1222        fn created(&self) -> io::Result<FileTime> {
1223            let mut times = [FileTime::default(); 4];
1224            let [creation, exit, kernel, user] = &mut times;
1225            // SAFETY: four valid out-parameters for an open process handle.
1226            if unsafe { GetProcessTimes(self.0, creation, exit, kernel, user) } == 0 {
1227                return Err(io::Error::last_os_error());
1228            }
1229            Ok(times[0])
1230        }
1231
1232        fn running(&self) -> bool {
1233            // SAFETY: the handle was opened with SYNCHRONIZE.
1234            unsafe { WaitForSingleObject(self.0, 0) == WAIT_TIMEOUT }
1235        }
1236
1237        /// The Win32 path of the image the process runs.
1238        fn image(&self) -> io::Result<PathBuf> {
1239            let mut buffer = vec![0u16; LONGEST_PATH];
1240            let mut length = buffer.len() as u32;
1241            // SAFETY: `buffer` holds `length` units; the call writes at most that many and
1242            // sets `length` to the number written, without the terminator.
1243            if unsafe { QueryFullProcessImageNameW(self.0, 0, buffer.as_mut_ptr(), &mut length) }
1244                == 0
1245            {
1246                return Err(io::Error::last_os_error());
1247            }
1248            Ok(PathBuf::from(OsString::from_wide(
1249                &buffer[..length as usize],
1250            )))
1251        }
1252    }
1253
1254    fn server_pid(pipe: RawHandle) -> io::Result<u32> {
1255        let mut pid = 0u32;
1256        // SAFETY: `pipe` is the client end of a named pipe; `pid` is a valid out-parameter.
1257        if unsafe { GetNamedPipeServerProcessId(pipe, &mut pid) } == 0 {
1258            return Err(io::Error::last_os_error());
1259        }
1260        Ok(pid)
1261    }
1262
1263    /// Volume serial number and file index: the same file under any name, as device and inode
1264    /// are on Unix.
1265    pub(super) fn file_id(path: &Path) -> Option<(u32, u64)> {
1266        let file = std::fs::File::open(path).ok()?;
1267        let mut info = ByHandleFileInformation::default();
1268        // SAFETY: an open file handle and a valid out-parameter of the documented layout.
1269        if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
1270            return None;
1271        }
1272        Some((
1273            info.volume_serial,
1274            (u64::from(info.index_high) << 32) | u64::from(info.index_low),
1275        ))
1276    }
1277
1278    /// Whether `expected` names the file at `found` (already canonical): the same canonical path
1279    /// without regard to case (NTFS keeps case and ignores it), or the same file by identity (a
1280    /// hard link).
1281    pub(super) fn same_executable(
1282        expected: &Path,
1283        found: &Path,
1284        found_id: Option<(u32, u64)>,
1285    ) -> bool {
1286        let Ok(expected) = std::fs::canonicalize(expected) else {
1287            return false;
1288        };
1289        let lower = |path: &Path| path.to_string_lossy().to_lowercase();
1290        if lower(&expected) == lower(found) {
1291            return true;
1292        }
1293        found_id.is_some() && file_id(&expected) == found_id
1294    }
1295
1296    /// A canonical path as a person writes it: without the `\\?\` prefix.
1297    fn plain(path: PathBuf) -> PathBuf {
1298        let text = path.to_string_lossy();
1299        match text.strip_prefix(r"\\?\UNC\") {
1300            Some(rest) => PathBuf::from(format!(r"\\{rest}")),
1301            None => match text.strip_prefix(r"\\?\") {
1302                Some(rest) => PathBuf::from(rest),
1303                None => path,
1304            },
1305        }
1306    }
1307
1308    /// Identify the process that holds the server end of `pipe` (the client end, opened with
1309    /// `SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION`) and check it against `expect`, before
1310    /// any byte is written:
1311    ///
1312    /// 1. the pipe's server pid, and that process's token user is this client's (the existing
1313    ///    same-user check, repeated here for its identity);
1314    /// 2. a handle to that process, kept until the end: it pins the process object, so the pid
1315    ///    cannot be given to another process while it is held;
1316    /// 3. the process's creation time, its image path, then the creation time again, that it is
1317    ///    still running, and that the pipe still reports the same pid;
1318    /// 4. the image path, canonicalised, is one of the expected executables (same canonical
1319    ///    path ignoring case, or the same file by volume and index);
1320    /// 5. the caller's extra verifier, if any. A code-signature check (`WinVerifyTrust` on
1321    ///    [`HostProcess::executable`]) belongs there.
1322    pub(crate) fn verify_pipe_host(
1323        pipe: RawHandle,
1324        expect: &HostExpectation,
1325    ) -> Result<HostProcess, HostConnectError> {
1326        let refuse = |pid: Option<u32>, exe: Option<PathBuf>, reason: HostIdentityReason| {
1327            HostConnectError::Identity(HostIdentityError {
1328                expected: expect.executables.clone(),
1329                found_pid: pid,
1330                found_executable: exe,
1331                reason,
1332            })
1333        };
1334        if expect.executables.is_empty() {
1335            return Err(refuse(None, None, HostIdentityReason::NoExpectation));
1336        }
1337
1338        let first = match verify_pipe_server_is_current_user(pipe) {
1339            Ok(id) => id,
1340            Err(e) => {
1341                // The host hung up before it could be identified: a connection problem.
1342                if e.peer_gone() {
1343                    return Err(HostConnectError::Io(io::Error::from(e)));
1344                }
1345                let pid = match &e {
1346                    PeerError::Mismatch(id) => id.pid,
1347                    _ => None,
1348                };
1349                return Err(refuse(pid, None, HostIdentityReason::Query(e.to_string())));
1350            }
1351        };
1352        let Some(pid) = first.pid else {
1353            return Err(refuse(None, None, HostIdentityReason::PidUnavailable));
1354        };
1355        let unreadable = |what: &str, e: io::Error| {
1356            refuse(
1357                Some(pid),
1358                None,
1359                HostIdentityReason::Unreadable(format!("{what}: {e}")),
1360            )
1361        };
1362        let changed = |why: &str| {
1363            refuse(
1364                Some(pid),
1365                None,
1366                HostIdentityReason::HostChanged(why.to_string()),
1367            )
1368        };
1369
1370        let process = match Process::open(pid) {
1371            Ok(process) => process,
1372            // No process has this id any more.
1373            Err(e) if e.raw_os_error() == Some(ERROR_INVALID_PARAMETER) => {
1374                return Err(changed("it exited before it could be opened"))
1375            }
1376            Err(e) => return Err(unreadable("open process", e)),
1377        };
1378        let before = process
1379            .created()
1380            .map_err(|e| unreadable("creation time", e))?;
1381        let image = process
1382            .image()
1383            .map_err(|e| unreadable("executable path", e))?;
1384        let after = process
1385            .created()
1386            .map_err(|e| unreadable("creation time", e))?;
1387        if before != after {
1388            return Err(changed(
1389                "its creation time differed before and after its executable path was read",
1390            ));
1391        }
1392        if !process.running() {
1393            return Err(changed("it exited while it was being identified"));
1394        }
1395        match server_pid(pipe) {
1396            Ok(again) if again == pid => {}
1397            _ => return Err(changed("the pipe no longer reports the same process id")),
1398        }
1399
1400        let found = std::fs::canonicalize(&image).unwrap_or(image);
1401        let id = file_id(&found);
1402        if !expect
1403            .executables
1404            .iter()
1405            .any(|expected| same_executable(expected, &found, id))
1406        {
1407            return Err(refuse(
1408                Some(pid),
1409                Some(plain(found)),
1410                HostIdentityReason::ExecutableMismatch,
1411            ));
1412        }
1413
1414        let host = HostProcess {
1415            pid,
1416            uid: None,
1417            sid: first.sid,
1418            executable: plain(found),
1419            audit_token: None,
1420        };
1421        if let Some(verifier) = &expect.verifier {
1422            if let Err(why) = verifier(&host) {
1423                return Err(refuse(
1424                    Some(pid),
1425                    Some(host.executable.clone()),
1426                    HostIdentityReason::Refused(why),
1427                ));
1428            }
1429        }
1430        // The process handle is released only now: every check above saw one process.
1431        drop(process);
1432        Ok(host)
1433    }
1434
1435    pub(super) fn start_marker_of(pid: u32) -> Option<(u64, u64)> {
1436        let t = Process::open(pid).ok()?.created().ok()?;
1437        Some(((u64::from(t.high) << 32) | u64::from(t.low), 0))
1438    }
1439
1440    /// Server side: identify the process that opened a named-pipe connection, from the
1441    /// OS-verified [`PeerIdentity`] recorded when it connected, and decide whether it is the
1442    /// program `expect` names. The same executable comparison and verifier as
1443    /// [`verify_pipe_host`], with the pipe handle no longer at hand: a handle to the process is
1444    /// opened and kept until every check has run (it pins the process object, so its pid cannot
1445    /// name another process meanwhile), and the creation time is read before and after the image
1446    /// path.
1447    ///
1448    /// Fails closed: no pid or no user SID recorded for the peer, no expected executable, a
1449    /// process that cannot be opened or has exited, a creation time that changed, an unreadable
1450    /// image, an executable that is not expected, a refusing verifier. An Authenticode check
1451    /// (`WinVerifyTrust` on [`HostProcess::executable`]) belongs in the verifier.
1452    pub(crate) fn verify_pid_host(
1453        peer: &PeerIdentity,
1454        expect: &HostExpectation,
1455    ) -> Result<HostProcess, HostIdentityError> {
1456        let refuse = |pid: Option<u32>, exe: Option<PathBuf>, reason: HostIdentityReason| {
1457            HostIdentityError {
1458                expected: expect.executables.clone(),
1459                found_pid: pid,
1460                found_executable: exe,
1461                reason,
1462            }
1463        };
1464        if expect.executables.is_empty() {
1465            return Err(refuse(None, None, HostIdentityReason::NoExpectation));
1466        }
1467        let Some(pid) = peer.pid else {
1468            return Err(refuse(None, None, HostIdentityReason::PidUnavailable));
1469        };
1470        let Some(sid) = peer.sid.clone() else {
1471            return Err(refuse(
1472                Some(pid),
1473                None,
1474                HostIdentityReason::Query("no user SID was recorded for the peer".to_string()),
1475            ));
1476        };
1477        let unreadable = |what: &str, e: io::Error| {
1478            refuse(
1479                Some(pid),
1480                None,
1481                HostIdentityReason::Unreadable(format!("{what}: {e}")),
1482            )
1483        };
1484        let changed = |why: &str| {
1485            refuse(
1486                Some(pid),
1487                None,
1488                HostIdentityReason::HostChanged(why.to_string()),
1489            )
1490        };
1491        let process = match Process::open(pid) {
1492            Ok(process) => process,
1493            Err(e) if e.raw_os_error() == Some(ERROR_INVALID_PARAMETER) => {
1494                return Err(changed("it exited before it could be opened"))
1495            }
1496            Err(e) => return Err(unreadable("open process", e)),
1497        };
1498        let before = process
1499            .created()
1500            .map_err(|e| unreadable("creation time", e))?;
1501        // The pid must still be the process that connected (creation time recorded at accept).
1502        let marker = ((u64::from(before.high) << 32) | u64::from(before.low), 0);
1503        if peer.started != Some(marker) {
1504            return Err(changed(concat!(
1505                "the process is not the one that connected (its creation time differs ",
1506                "from the one recorded at connect, or none was recorded)"
1507            )));
1508        }
1509        let image = process
1510            .image()
1511            .map_err(|e| unreadable("executable path", e))?;
1512        let after = process
1513            .created()
1514            .map_err(|e| unreadable("creation time", e))?;
1515        if before != after {
1516            return Err(changed(
1517                "its creation time differed before and after its executable path was read",
1518            ));
1519        }
1520        if !process.running() {
1521            return Err(changed("it exited while it was being identified"));
1522        }
1523
1524        let found = std::fs::canonicalize(&image).unwrap_or(image);
1525        let id = file_id(&found);
1526        if !expect
1527            .executables
1528            .iter()
1529            .any(|expected| same_executable(expected, &found, id))
1530        {
1531            return Err(refuse(
1532                Some(pid),
1533                Some(plain(found)),
1534                HostIdentityReason::ExecutableMismatch,
1535            ));
1536        }
1537        let host = HostProcess {
1538            pid,
1539            uid: None,
1540            sid: Some(sid),
1541            executable: plain(found),
1542            audit_token: None,
1543        };
1544        if let Some(verifier) = &expect.verifier {
1545            if let Err(why) = verifier(&host) {
1546                return Err(refuse(
1547                    Some(pid),
1548                    Some(host.executable.clone()),
1549                    HostIdentityReason::Refused(why),
1550                ));
1551            }
1552        }
1553        // Released only now: every check above saw one process.
1554        drop(process);
1555        Ok(host)
1556    }
1557
1558    #[cfg(test)]
1559    mod tests {
1560        use super::*;
1561
1562        #[test]
1563        fn the_same_file_matches_by_path_in_any_case_and_by_identity_through_a_hard_link() {
1564            let dir = tempfile::tempdir().unwrap();
1565            let real = dir.path().join("HostBin.exe");
1566            std::fs::write(&real, b"binary").unwrap();
1567            let found = std::fs::canonicalize(&real).unwrap();
1568            let id = file_id(&found);
1569            assert!(id.is_some());
1570
1571            assert!(same_executable(&real, &found, id));
1572            // NTFS ignores case: another spelling names the same file.
1573            assert!(same_executable(&dir.path().join("hostbin.EXE"), &found, id));
1574            assert!(same_executable(
1575                &dir.path().join("hostbin.EXE"),
1576                &found,
1577                None
1578            ));
1579
1580            let hard = dir.path().join("hard.exe");
1581            std::fs::hard_link(&real, &hard).unwrap();
1582            assert!(same_executable(&hard, &found, id), "same file, other name");
1583            // Without a file identity only the canonical path can match.
1584            assert!(!same_executable(&hard, &found, None));
1585
1586            // Same bytes, different file: not the running executable.
1587            let copy = dir.path().join("copy.exe");
1588            std::fs::copy(&real, &copy).unwrap();
1589            assert!(!same_executable(&copy, &found, id));
1590            assert!(!same_executable(
1591                &dir.path().join("missing.exe"),
1592                &found,
1593                id
1594            ));
1595        }
1596
1597        #[test]
1598        fn this_process_is_described_by_its_own_handle() {
1599            let process = Process::open(std::process::id()).unwrap();
1600            assert!(process.running());
1601            assert_eq!(process.created().unwrap(), process.created().unwrap());
1602            let image = std::fs::canonicalize(process.image().unwrap()).unwrap();
1603            let me = std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap();
1604            assert!(same_executable(&me, &image, file_id(&image)));
1605            assert!(!plain(image).to_string_lossy().starts_with(r"\\?\"));
1606        }
1607
1608        #[test]
1609        fn a_process_id_nothing_holds_cannot_be_opened() {
1610            // Process ids are multiples of four; this one is not.
1611            assert!(Process::open(0xFFFF_FFF3).is_err());
1612        }
1613    }
1614}
1615
1616#[cfg(test)]
1617mod tests {
1618    use super::*;
1619
1620    #[test]
1621    fn a_bare_file_name_is_one_component() {
1622        assert!(is_single_component(OsStr::new("mailright")));
1623        assert!(is_single_component(OsStr::new("mailright.exe")));
1624        assert!(!is_single_component(OsStr::new("")));
1625        assert!(!is_single_component(OsStr::new("..")));
1626        assert!(!is_single_component(OsStr::new("a/b")));
1627        assert!(!is_single_component(OsStr::new("/abs")));
1628    }
1629
1630    #[test]
1631    fn install_relative_paths_are_derived_from_where_the_engine_lives() {
1632        let engine = Path::new("/Applications/MailRight.app/Contents/Resources/bin/engine");
1633        assert_eq!(
1634            bundle_main(engine, OsStr::new("mailright")),
1635            Some(PathBuf::from(
1636                "/Applications/MailRight.app/Contents/MacOS/mailright"
1637            ))
1638        );
1639        assert_eq!(
1640            up_and_join(engine, 1, Path::new("app")),
1641            Some(PathBuf::from(
1642                "/Applications/MailRight.app/Contents/Resources/bin/app"
1643            ))
1644        );
1645        assert_eq!(
1646            up_and_join(engine, 3, Path::new("MacOS/mailright")),
1647            Some(PathBuf::from(
1648                "/Applications/MailRight.app/Contents/MacOS/mailright"
1649            ))
1650        );
1651        // Not in a bundle; level 0 is the executable itself; absolute relative paths are refused.
1652        assert_eq!(
1653            bundle_main(Path::new("/opt/app/bin/engine"), OsStr::new("x")),
1654            None
1655        );
1656        assert_eq!(up_and_join(engine, 0, Path::new("x")), None);
1657        assert_eq!(up_and_join(engine, 1, Path::new("/etc/passwd")), None);
1658        // Windows: a rooted path without a drive, and a drive-relative one, are not relative.
1659        #[cfg(windows)]
1660        for escape in [r"\Windows\System32\cmd.exe", "C:cmd.exe", r"C:\cmd.exe"] {
1661            assert_eq!(up_and_join(engine, 1, Path::new(escape)), None, "{escape}");
1662        }
1663        assert_eq!(up_and_join(Path::new("engine"), 5, Path::new("x")), None);
1664    }
1665
1666    #[test]
1667    fn helpers_resolve_against_the_running_test_binary() {
1668        let exe = std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap();
1669        let sibling = HostExpectation::sibling_executable("host-binary").unwrap();
1670        assert_eq!(
1671            sibling.executables(),
1672            [exe.parent().unwrap().join("host-binary")]
1673        );
1674        assert!(HostExpectation::sibling_executable("a/b").is_err());
1675        assert!(HostExpectation::sibling_executable("..").is_err());
1676        assert!(HostExpectation::bundle_executable("x/y").is_err());
1677        let up2 = HostExpectation::relative_to_current_exe(2, "bin/host").unwrap();
1678        assert_eq!(
1679            up2.executables(),
1680            [exe.parent().unwrap().parent().unwrap().join("bin/host")]
1681        );
1682        assert!(HostExpectation::relative_to_current_exe(0, "x").is_err());
1683    }
1684
1685    #[test]
1686    fn the_error_says_what_was_expected_and_what_was_found() {
1687        let e = HostIdentityError {
1688            expected: vec![PathBuf::from("/a/app"), PathBuf::from("/b/app")],
1689            found_pid: Some(42),
1690            found_executable: Some(PathBuf::from("/tmp/evil")),
1691            reason: HostIdentityReason::ExecutableMismatch,
1692        };
1693        let text = e.to_string();
1694        assert!(
1695            text.contains("pid 42") && text.contains("/tmp/evil"),
1696            "{text}"
1697        );
1698        assert!(text.contains("/a/app or /b/app"), "{text}");
1699        let none = HostIdentityError {
1700            expected: Vec::new(),
1701            found_pid: None,
1702            found_executable: None,
1703            reason: HostIdentityReason::NoExpectation,
1704        };
1705        assert!(none.to_string().contains("none supplied"));
1706    }
1707
1708    #[test]
1709    fn a_refusal_collapses_to_a_non_retryable_wire_error() {
1710        let e = HostIdentityError {
1711            expected: vec![PathBuf::from("/a/app")],
1712            found_pid: Some(7),
1713            found_executable: None,
1714            reason: HostIdentityReason::PidUnavailable,
1715        };
1716        let wire: crate::ServiceError = e.clone().into();
1717        assert_eq!(wire.code, crate::ERROR_HOST_IDENTITY);
1718        assert!(!wire.retryable);
1719        assert!(wire.message.contains("pid 7"));
1720        let c: crate::ConnectError = e.into();
1721        assert_eq!(c.into_service_error(), wire);
1722    }
1723}