1use std::ffi::OsStr;
16use std::fmt;
17use std::io;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
26#[non_exhaustive]
27pub struct HostProcess {
28 pub pid: u32,
31 pub uid: Option<u32>,
33 pub sid: Option<String>,
35 pub executable: PathBuf,
38 pub audit_token: Option<[u32; 8]>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum HostIdentityReason {
50 NoExpectation,
52 UidMismatch { host: Option<u32>, client: u32 },
54 PidUnavailable,
56 Unreadable(String),
58 ExecutableMismatch,
60 HostChanged(String),
63 Refused(String),
65 Unverified {
68 platform: &'static str,
69 detail: &'static str,
70 },
71 Query(String),
73 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#[derive(Debug, Clone, PartialEq, Eq)]
144#[non_exhaustive]
145pub struct HostIdentityError {
146 pub expected: Vec<PathBuf>,
148 pub found_pid: Option<u32>,
150 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#[derive(Debug)]
187pub(crate) enum HostConnectError {
188 Io(io::Error),
190 Identity(HostIdentityError),
192}
193
194type Verifier = Arc<dyn Fn(&HostProcess) -> Result<(), String> + Send + Sync>;
195
196#[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
236fn is_single_component(name: &OsStr) -> bool {
238 let path = Path::new(name);
239 path.file_name() == Some(name) && path.components().count() == 1
240}
241
242fn 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
259fn 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 pub fn executable(path: impl Into<PathBuf>) -> Self {
270 Self {
271 executables: vec![path.into()],
272 verifier: None,
273 }
274 }
275
276 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 pub fn or_executable(mut self, path: impl Into<PathBuf>) -> Self {
290 self.executables.push(path.into());
291 self
292 }
293
294 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 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 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 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 pub fn executables(&self) -> &[PathBuf] {
361 &self.executables
362 }
363}
364
365pub(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
402pub(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 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 pub struct ExeInfo {
444 pub path: PathBuf,
445 pub id: Option<FileId>,
448 }
449
450 #[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 #[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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 pub fn audit_token(_stream: &UnixStream) -> Option<[u32; 8]> {
951 None
952 }
953 }
954
955 #[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 #[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 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 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 let copy = dir.path().join("copy");
1084 std::fs::copy(&real, ©).unwrap();
1085 assert!(!same_executable(©, &found, id));
1086
1087 assert!(!same_executable(&dir.path().join("missing"), &found, id));
1088 assert!(same_executable(&link, &found, None));
1090 assert!(!same_executable(&hard, &found, None));
1091 }
1092
1093 #[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#[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 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 #[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 struct Process(RawHandle);
1181
1182 impl Drop for Process {
1183 fn drop(&mut self) {
1184 unsafe { CloseHandle(self.0) };
1186 }
1187 }
1188
1189 impl Process {
1190 fn open(pid: u32) -> io::Result<Process> {
1191 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 fn created(&self) -> io::Result<FileTime> {
1202 let mut times = [FileTime::default(); 4];
1203 let [creation, exit, kernel, user] = &mut times;
1204 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 unsafe { WaitForSingleObject(self.0, 0) == WAIT_TIMEOUT }
1214 }
1215
1216 fn image(&self) -> io::Result<PathBuf> {
1218 let mut buffer = vec![0u16; LONGEST_PATH];
1219 let mut length = buffer.len() as u32;
1220 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 if unsafe { GetNamedPipeServerProcessId(pipe, &mut pid) } == 0 {
1237 return Err(io::Error::last_os_error());
1238 }
1239 Ok(pid)
1240 }
1241
1242 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 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 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 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 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 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 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 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 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 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 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 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 assert!(!same_executable(&hard, &found, None));
1564
1565 let copy = dir.path().join("copy.exe");
1567 std::fs::copy(&real, ©).unwrap();
1568 assert!(!same_executable(©, &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 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 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 #[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}