use std::ffi::OsStr;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HostProcess {
pub pid: u32,
pub uid: Option<u32>,
pub sid: Option<String>,
pub executable: PathBuf,
pub audit_token: Option<[u32; 8]>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum HostIdentityReason {
NoExpectation,
UidMismatch { host: Option<u32>, client: u32 },
PidUnavailable,
Unreadable(String),
ExecutableMismatch,
HostChanged(String),
Refused(String),
Unverified {
platform: &'static str,
detail: &'static str,
},
Query(String),
NotLocalPeer,
}
impl fmt::Display for HostIdentityReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HostIdentityReason::NoExpectation => {
write!(
f,
"no expected host executable was supplied, so no host can match"
)
}
HostIdentityReason::UidMismatch { host, client } => match host {
Some(host) => write!(
f,
"the host runs as uid {host}, this client as uid {client}"
),
None => write!(
f,
"the host's uid is unknown, this client runs as uid {client}"
),
},
HostIdentityReason::PidUnavailable => {
write!(f, "the OS did not report the host's process id")
}
HostIdentityReason::Unreadable(why) => {
write!(f, "the OS would not describe the host process ({why})")
}
HostIdentityReason::ExecutableMismatch => {
write!(
f,
"the host's executable is not one of the expected executables"
)
}
HostIdentityReason::HostChanged(why) => {
write!(
f,
"the host process changed while it was identified ({why})"
)
}
HostIdentityReason::Refused(why) => {
write!(f, "the caller's extra host verification refused it ({why})")
}
HostIdentityReason::Unverified { platform, detail } => {
write!(
f,
"host verification is unavailable on {platform} ({detail})"
)
}
HostIdentityReason::Query(why) => {
write!(f, "the OS could not identify the peer ({why})")
}
HostIdentityReason::NotLocalPeer => {
write!(
f,
"the caller is not a local app, so it has no process to identify"
)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HostIdentityError {
pub expected: Vec<PathBuf>,
pub found_pid: Option<u32>,
pub found_executable: Option<PathBuf>,
pub reason: HostIdentityReason,
}
impl fmt::Display for HostIdentityError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"the service host failed identity verification: {}",
self.reason
)?;
if let Some(pid) = self.found_pid {
write!(f, "; host pid {pid}")?;
}
if let Some(exe) = &self.found_executable {
write!(f, "; host executable {}", exe.display())?;
}
if self.expected.is_empty() {
write!(f, "; expected: none supplied")
} else {
write!(f, "; expected ")?;
for (i, path) in self.expected.iter().enumerate() {
if i > 0 {
write!(f, " or ")?;
}
write!(f, "{}", path.display())?;
}
Ok(())
}
}
}
impl std::error::Error for HostIdentityError {}
#[derive(Debug)]
pub(crate) enum HostConnectError {
Io(io::Error),
Identity(HostIdentityError),
}
type Verifier = Arc<dyn Fn(&HostProcess) -> Result<(), String> + Send + Sync>;
#[derive(Clone, Default)]
pub struct HostExpectation {
executables: Vec<PathBuf>,
verifier: Option<Verifier>,
}
impl fmt::Debug for HostExpectation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HostExpectation")
.field("executables", &self.executables)
.field("verifier", &self.verifier.as_ref().map(|_| "<fn>"))
.finish()
}
}
fn invalid(what: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, what.to_string())
}
fn current_exe_canonical() -> io::Result<PathBuf> {
std::fs::canonicalize(std::env::current_exe()?)
}
fn is_single_component(name: &OsStr) -> bool {
let path = Path::new(name);
path.file_name() == Some(name) && path.components().count() == 1
}
fn up_and_join(exe: &Path, levels_up: usize, relative: &Path) -> Option<PathBuf> {
use std::path::Component;
let leaves_base = relative
.components()
.any(|part| matches!(part, Component::Prefix(_) | Component::RootDir));
if levels_up == 0 || relative.is_absolute() || leaves_base {
return None;
}
exe.ancestors()
.nth(levels_up)
.map(|base| base.join(relative))
}
fn bundle_main(exe: &Path, file_name: &OsStr) -> Option<PathBuf> {
exe.ancestors()
.skip(1)
.find(|dir| dir.extension() == Some(OsStr::new("app")))
.map(|bundle| bundle.join("Contents").join("MacOS").join(file_name))
}
impl HostExpectation {
pub fn executable(path: impl Into<PathBuf>) -> Self {
Self {
executables: vec![path.into()],
verifier: None,
}
}
pub fn any_of<I, P>(paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
Self {
executables: paths.into_iter().map(Into::into).collect(),
verifier: None,
}
}
pub fn or_executable(mut self, path: impl Into<PathBuf>) -> Self {
self.executables.push(path.into());
self
}
pub fn sibling_executable(file_name: impl AsRef<OsStr>) -> io::Result<Self> {
let name = file_name.as_ref();
if !is_single_component(name) {
return Err(invalid("the host file name must be a bare file name"));
}
Self::relative_to_current_exe(1, name)
}
pub fn bundle_executable(file_name: impl AsRef<OsStr>) -> io::Result<Self> {
let name = file_name.as_ref();
if !is_single_component(name) {
return Err(invalid("the host file name must be a bare file name"));
}
let exe = current_exe_canonical()?;
let path = bundle_main(&exe, name).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"the running executable is not inside an .app bundle",
)
})?;
Ok(Self::executable(path))
}
pub fn relative_to_current_exe(
levels_up: usize,
relative: impl AsRef<Path>,
) -> io::Result<Self> {
let exe = current_exe_canonical()?;
let path = up_and_join(&exe, levels_up, relative.as_ref()).ok_or_else(|| {
invalid("no such directory above the running executable, or the path is absolute")
})?;
Ok(Self::executable(path))
}
pub fn with_verifier(
mut self,
check: impl Fn(&HostProcess) -> Result<(), String> + Send + Sync + 'static,
) -> Self {
self.verifier = Some(Arc::new(check));
self
}
pub fn executables(&self) -> &[PathBuf] {
&self.executables
}
}
pub(crate) fn verify_peer_process(
peer: Option<&crate::peer::PeerIdentity>,
expect: &HostExpectation,
) -> Result<HostProcess, HostIdentityError> {
let Some(peer) = peer else {
return Err(HostIdentityError {
expected: expect.executables.clone(),
found_pid: None,
found_executable: None,
reason: HostIdentityReason::NotLocalPeer,
});
};
#[cfg(unix)]
{
verify_unix_pid(peer, expect)
}
#[cfg(windows)]
{
verify_pid_host(peer, expect)
}
#[cfg(not(any(unix, windows)))]
{
Err(HostIdentityError {
expected: expect.executables.clone(),
found_pid: peer.pid,
found_executable: None,
reason: HostIdentityReason::Unverified {
platform: std::env::consts::OS,
detail: "no process identification is implemented for this platform",
},
})
}
}
pub(crate) fn process_start_marker(pid: u32) -> Option<(u64, u64)> {
#[cfg(unix)]
{
unix_impl::start_marker_of(pid)
}
#[cfg(windows)]
{
windows_impl::start_marker_of(pid)
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
None
}
}
#[cfg(unix)]
pub(crate) use unix_impl::{peer_audit_token, verify_unix_host, verify_unix_pid};
#[cfg(unix)]
mod unix_impl {
use super::{
HostConnectError, HostExpectation, HostIdentityError, HostIdentityReason, HostProcess,
};
use crate::peer::{current_euid, verify_peer_is_current_user, PeerError, PeerIdentity};
use std::io;
use std::os::unix::fs::MetadataExt;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
pub type FileId = (u64, u64);
pub fn file_id(path: &Path) -> Option<FileId> {
let md = std::fs::metadata(path).ok()?;
Some((md.dev(), md.ino()))
}
pub struct ExeInfo {
pub path: PathBuf,
pub id: Option<FileId>,
}
#[derive(PartialEq, Eq)]
struct Witness {
start: (u64, u64),
token: Option<[u32; 8]>,
}
enum ObserveError {
Gone,
Unreadable(String),
}
impl From<ObserveError> for HostIdentityReason {
fn from(e: ObserveError) -> Self {
match e {
ObserveError::Gone => HostIdentityReason::HostChanged(
"it exited before it could be identified".to_string(),
),
ObserveError::Unreadable(why) => HostIdentityReason::Unreadable(why),
}
}
}
#[cfg(target_os = "macos")]
fn missing_or_err<T>() -> io::Result<Option<T>> {
let e = io::Error::last_os_error();
match e.raw_os_error() {
Some(3) | Some(0) | None => Ok(None),
_ => Err(e),
}
}
fn witness(stream: &UnixStream, pid: u32) -> Result<Witness, ObserveError> {
let token = os::audit_token(stream);
let start = match os::start_marker(pid) {
Ok(Some(start)) => start,
Ok(None) => return Err(ObserveError::Gone),
Err(e) => return Err(ObserveError::Unreadable(format!("process start time: {e}"))),
};
Ok(Witness { start, token })
}
pub fn same_executable(expected: &Path, found: &Path, found_id: Option<FileId>) -> bool {
let Ok(canonical) = std::fs::canonicalize(expected) else {
return false;
};
if canonical == found {
return true;
}
match (found_id, file_id(&canonical)) {
(Some(running), Some(named)) => running == named,
_ => false,
}
}
pub(crate) fn verify_unix_host(
stream: &UnixStream,
expect: &HostExpectation,
) -> Result<HostProcess, HostConnectError> {
let refuse = |pid: Option<u32>, exe: Option<PathBuf>, reason: HostIdentityReason| {
HostConnectError::Identity(HostIdentityError {
expected: expect.executables.clone(),
found_pid: pid,
found_executable: exe,
reason,
})
};
if expect.executables.is_empty() {
return Err(refuse(None, None, HostIdentityReason::NoExpectation));
}
if !os::SUPPORTED {
return Err(refuse(
None,
None,
HostIdentityReason::Unverified {
platform: std::env::consts::OS,
detail: "reading a process's executable path is not implemented for this Unix",
},
));
}
let first = match verify_peer_is_current_user(stream) {
Ok(id) => id,
Err(PeerError::Mismatch(id)) => {
return Err(refuse(
id.pid,
None,
HostIdentityReason::UidMismatch {
host: id.uid,
client: current_euid(),
},
))
}
Err(PeerError::Unsupported(detail)) => {
return Err(refuse(
None,
None,
HostIdentityReason::Unverified {
platform: std::env::consts::OS,
detail,
},
))
}
Err(e) => {
if e.peer_gone() {
return Err(HostConnectError::Io(io::Error::from(e)));
}
return Err(refuse(None, None, HostIdentityReason::Query(e.to_string())));
}
};
let Some(pid) = first.pid else {
return Err(refuse(None, None, HostIdentityReason::PidUnavailable));
};
let observe = |e: ObserveError| refuse(Some(pid), None, e.into());
let changed = |why: &str| {
refuse(
Some(pid),
None,
HostIdentityReason::HostChanged(why.to_string()),
)
};
let before = witness(stream, pid).map_err(&observe)?;
let exe = match os::executable_of(pid) {
Ok(Some(exe)) => exe,
Ok(None) => return Err(observe(ObserveError::Gone)),
Err(e) => {
return Err(observe(ObserveError::Unreadable(format!(
"executable path: {e}"
))))
}
};
let after = witness(stream, pid).map_err(&observe)?;
if before != after {
return Err(changed(
"its identity differed before and after its executable path was read",
));
}
match verify_peer_is_current_user(stream) {
Ok(again) if again.pid == Some(pid) => {}
_ => return Err(changed("the socket no longer reports the same process id")),
}
conclude(expect, pid, exe, after.token).map_err(HostConnectError::Identity)
}
fn conclude(
expect: &HostExpectation,
pid: u32,
exe: ExeInfo,
audit_token: Option<[u32; 8]>,
) -> Result<HostProcess, HostIdentityError> {
let refuse = |exe: Option<PathBuf>, reason: HostIdentityReason| HostIdentityError {
expected: expect.executables.clone(),
found_pid: Some(pid),
found_executable: exe,
reason,
};
let ExeInfo { path, id } = exe;
let found = std::fs::canonicalize(&path).unwrap_or(path);
if !expect
.executables
.iter()
.any(|expected| same_executable(expected, &found, id))
{
return Err(refuse(Some(found), HostIdentityReason::ExecutableMismatch));
}
let host = HostProcess {
pid,
uid: Some(current_euid()),
sid: None,
executable: found,
audit_token,
};
if let Some(verifier) = &expect.verifier {
if let Err(why) = verifier(&host) {
return Err(refuse(
Some(host.executable.clone()),
HostIdentityReason::Refused(why),
));
}
}
Ok(host)
}
pub(crate) fn verify_unix_pid(
peer: &PeerIdentity,
expect: &HostExpectation,
) -> Result<HostProcess, HostIdentityError> {
let refuse = |pid: Option<u32>, exe: Option<PathBuf>, reason: HostIdentityReason| {
HostIdentityError {
expected: expect.executables.clone(),
found_pid: pid,
found_executable: exe,
reason,
}
};
if expect.executables.is_empty() {
return Err(refuse(None, None, HostIdentityReason::NoExpectation));
}
if !os::SUPPORTED {
return Err(refuse(
None,
None,
HostIdentityReason::Unverified {
platform: std::env::consts::OS,
detail: "reading a process's executable path is not implemented for this Unix",
},
));
}
if peer.uid != Some(current_euid()) {
return Err(refuse(
peer.pid,
None,
HostIdentityReason::UidMismatch {
host: peer.uid,
client: current_euid(),
},
));
}
let Some(pid) = peer.pid else {
return Err(refuse(None, None, HostIdentityReason::PidUnavailable));
};
if let Some(token) = peer.audit_token {
if token[5] != pid {
return Err(refuse(
Some(pid),
None,
HostIdentityReason::HostChanged(
"the audit token and the process id name different processes".to_string(),
),
));
}
}
let observe = |e: ObserveError| refuse(Some(pid), None, e.into());
let start = |pid| match os::start_marker(pid) {
Ok(Some(start)) => Ok(start),
Ok(None) => Err(ObserveError::Gone),
Err(e) => Err(ObserveError::Unreadable(format!("process start time: {e}"))),
};
let before = start(pid).map_err(&observe)?;
if peer.started != Some(before) {
return Err(refuse(
Some(pid),
None,
HostIdentityReason::HostChanged(
concat!(
"the process is not the one that connected (its start time differs ",
"from the one recorded at connect, or none was recorded)"
)
.to_string(),
),
));
}
let exe = match os::executable_of(pid) {
Ok(Some(exe)) => exe,
Ok(None) => return Err(observe(ObserveError::Gone)),
Err(e) => {
return Err(observe(ObserveError::Unreadable(format!(
"executable path: {e}"
))))
}
};
let after = start(pid).map_err(&observe)?;
if before != after {
return Err(refuse(
Some(pid),
None,
HostIdentityReason::HostChanged(
"its start time differed before and after its executable path was read"
.to_string(),
),
));
}
conclude(expect, pid, exe, peer.audit_token)
}
pub(crate) fn start_marker_of(pid: u32) -> Option<(u64, u64)> {
os::start_marker(pid).ok().flatten()
}
pub(crate) fn peer_audit_token(stream: &UnixStream) -> Option<[u32; 8]> {
os::audit_token(stream)
}
#[cfg(target_os = "macos")]
mod os {
use super::{file_id, missing_or_err, ExeInfo};
use std::ffi::{c_void, OsString};
use std::io;
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
pub const SUPPORTED: bool = true;
#[repr(C)]
#[allow(dead_code)]
struct ProcBsdInfo {
pbi_flags: u32,
pbi_status: u32,
pbi_xstatus: u32,
pbi_pid: u32,
pbi_ppid: u32,
pbi_uid: u32,
pbi_gid: u32,
pbi_ruid: u32,
pbi_rgid: u32,
pbi_svuid: u32,
pbi_svgid: u32,
rfu_1: u32,
pbi_comm: [u8; 16],
pbi_name: [u8; 32],
pbi_nfiles: u32,
pbi_pgid: u32,
pbi_pjobc: u32,
e_tdev: u32,
e_tpgid: u32,
pbi_nice: i32,
pbi_start_tvsec: u64,
pbi_start_tvusec: u64,
}
const _: () = assert!(std::mem::size_of::<ProcBsdInfo>() == 136);
const PROC_PIDTBSDINFO: i32 = 3;
const PROC_PIDPATHINFO_MAXSIZE: usize = 4 * 1024;
const SOL_LOCAL: i32 = 0;
const LOCAL_PEERTOKEN: i32 = 0x006;
extern "C" {
fn proc_pidinfo(
pid: i32,
flavor: i32,
arg: u64,
buffer: *mut c_void,
buffersize: i32,
) -> i32;
fn proc_pidpath(pid: i32, buffer: *mut c_void, buffersize: u32) -> i32;
fn getsockopt(fd: i32, level: i32, name: i32, val: *mut c_void, len: *mut u32) -> i32;
}
fn to_pid(pid: u32) -> io::Result<i32> {
i32::try_from(pid)
.ok()
.filter(|p| *p > 0)
.ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))
}
pub fn start_marker(pid: u32) -> io::Result<Option<(u64, u64)>> {
let raw_pid = to_pid(pid)?;
let mut info = std::mem::MaybeUninit::<ProcBsdInfo>::zeroed();
let size = std::mem::size_of::<ProcBsdInfo>() as i32;
let written = unsafe {
proc_pidinfo(
raw_pid,
PROC_PIDTBSDINFO,
0,
info.as_mut_ptr().cast::<c_void>(),
size,
)
};
if written != size {
return missing_or_err();
}
let info = unsafe { info.assume_init() };
Ok((info.pbi_pid == pid).then_some((info.pbi_start_tvsec, info.pbi_start_tvusec)))
}
pub fn executable_of(pid: u32) -> io::Result<Option<ExeInfo>> {
let raw_pid = to_pid(pid)?;
let mut buf = vec![0u8; PROC_PIDPATHINFO_MAXSIZE];
let n = unsafe {
proc_pidpath(raw_pid, buf.as_mut_ptr().cast::<c_void>(), buf.len() as u32)
};
if n <= 0 {
return missing_or_err();
}
buf.truncate(n as usize);
if let Some(nul) = buf.iter().position(|b| *b == 0) {
buf.truncate(nul);
}
let path = PathBuf::from(OsString::from_vec(buf));
let id = file_id(&path);
Ok(Some(ExeInfo { path, id }))
}
pub fn audit_token(stream: &UnixStream) -> Option<[u32; 8]> {
let mut token = [0u32; 8];
let mut len = std::mem::size_of_val(&token) as u32;
let rc = unsafe {
getsockopt(
stream.as_raw_fd(),
SOL_LOCAL,
LOCAL_PEERTOKEN,
token.as_mut_ptr().cast::<c_void>(),
&mut len,
)
};
(rc == 0 && len as usize == std::mem::size_of_val(&token)).then_some(token)
}
}
#[cfg(any(target_os = "linux", target_os = "android"))]
mod os {
use super::{is_no_such_process, ExeInfo};
use std::io;
use std::os::unix::fs::MetadataExt;
use std::os::unix::net::UnixStream;
pub const SUPPORTED: bool = true;
pub fn start_marker(pid: u32) -> io::Result<Option<(u64, u64)>> {
let text = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(text) => text,
Err(e) if is_no_such_process(&e) => return Ok(None),
Err(e) => return Err(e),
};
let malformed = || io::Error::new(io::ErrorKind::InvalidData, "unreadable /proc stat");
let rest = text.rsplit_once(')').ok_or_else(malformed)?.1;
let ticks = rest
.split_whitespace()
.nth(19)
.and_then(|field| field.parse::<u64>().ok())
.ok_or_else(malformed)?;
Ok(Some((ticks, 0)))
}
pub fn executable_of(pid: u32) -> io::Result<Option<ExeInfo>> {
let link = format!("/proc/{pid}/exe");
let path = match std::fs::read_link(&link) {
Ok(path) => path,
Err(e) if is_no_such_process(&e) => return Ok(None),
Err(e) => return Err(e),
};
let id = std::fs::metadata(&link).ok().map(|md| (md.dev(), md.ino()));
Ok(Some(ExeInfo { path, id }))
}
pub fn audit_token(_stream: &UnixStream) -> Option<[u32; 8]> {
None
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "android")))]
mod os {
use super::ExeInfo;
use std::io;
use std::os::unix::net::UnixStream;
pub const SUPPORTED: bool = false;
pub fn start_marker(_pid: u32) -> io::Result<Option<(u64, u64)>> {
Err(io::Error::from(io::ErrorKind::Unsupported))
}
pub fn executable_of(_pid: u32) -> io::Result<Option<ExeInfo>> {
Err(io::Error::from(io::ErrorKind::Unsupported))
}
pub fn audit_token(_stream: &UnixStream) -> Option<[u32; 8]> {
None
}
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn is_no_such_process(e: &io::Error) -> bool {
e.kind() == io::ErrorKind::NotFound || e.raw_os_error() == Some(3)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::net::UnixListener;
fn canonical_current_exe() -> PathBuf {
std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap()
}
#[test]
fn the_kernel_describes_this_process_and_a_missing_pid_is_gone_not_an_error() {
let me = std::process::id();
let exe = os::executable_of(me).unwrap().expect("this process exists");
assert_eq!(
std::fs::canonicalize(&exe.path).unwrap(),
canonical_current_exe()
);
let start = os::start_marker(me).unwrap().expect("this process exists");
assert!(start.0 > 0);
assert_eq!(os::start_marker(me).unwrap(), Some(start));
let nobody = i32::MAX as u32;
assert!(os::start_marker(nobody).unwrap().is_none());
assert!(os::executable_of(nobody).unwrap().is_none());
}
#[test]
fn a_listener_in_this_process_is_identified_and_matched_by_its_executable() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("h.sock");
let _listener = UnixListener::bind(&sock).unwrap();
let stream = UnixStream::connect(&sock).unwrap();
let me = canonical_current_exe();
let host = verify_unix_host(&stream, &HostExpectation::executable(&me))
.unwrap_or_else(|e| panic!("this binary is the host: {e:?}"));
assert_eq!(host.pid, std::process::id());
assert_eq!(host.executable, me);
assert_eq!(host.uid, Some(current_euid()));
let other = dir.path().join("other");
std::fs::write(&other, b"x").unwrap();
let Err(HostConnectError::Identity(e)) =
verify_unix_host(&stream, &HostExpectation::executable(&other))
else {
panic!("a different executable must be refused");
};
assert_eq!(e.reason, HostIdentityReason::ExecutableMismatch);
assert_eq!(e.found_pid, Some(std::process::id()));
assert_eq!(e.found_executable, Some(me.clone()));
assert_eq!(e.expected, vec![other.clone()]);
let any = HostExpectation::any_of([other, dir.path().join("missing"), me]);
assert!(verify_unix_host(&stream, &any).is_ok());
}
#[test]
fn no_expectation_and_a_refusing_verifier_both_fail_closed() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("h.sock");
let _listener = UnixListener::bind(&sock).unwrap();
let stream = UnixStream::connect(&sock).unwrap();
let Err(HostConnectError::Identity(e)) =
verify_unix_host(&stream, &HostExpectation::any_of(Vec::<PathBuf>::new()))
else {
panic!("an empty expectation must refuse");
};
assert_eq!(e.reason, HostIdentityReason::NoExpectation);
let expect = HostExpectation::executable(canonical_current_exe())
.with_verifier(|host| Err(format!("pid {} is not signed by us", host.pid)));
let Err(HostConnectError::Identity(e)) = verify_unix_host(&stream, &expect) else {
panic!("a refusing verifier must refuse");
};
assert!(
matches!(e.reason, HostIdentityReason::Refused(ref why) if why.contains("not signed"))
);
assert_eq!(e.found_executable, Some(canonical_current_exe()));
}
#[test]
fn a_symlink_a_hard_link_and_an_unknown_path_are_judged_by_the_file_not_the_spelling() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real");
std::fs::write(&real, b"binary").unwrap();
let found = std::fs::canonicalize(&real).unwrap();
let id = file_id(&found);
assert!(id.is_some());
let link = dir.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(same_executable(&link, &found, id));
let hard = dir.path().join("hard");
std::fs::hard_link(&real, &hard).unwrap();
assert!(same_executable(&hard, &found, id), "same file, other name");
let copy = dir.path().join("copy");
std::fs::copy(&real, ©).unwrap();
assert!(!same_executable(©, &found, id));
assert!(!same_executable(&dir.path().join("missing"), &found, id));
assert!(same_executable(&link, &found, None));
assert!(!same_executable(&hard, &found, None));
}
#[cfg(target_os = "macos")]
#[test]
fn a_case_variant_spelling_of_the_expected_path_names_the_same_file() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("hostbin");
std::fs::write(&real, b"binary").unwrap();
let found = std::fs::canonicalize(&real).unwrap();
let id = file_id(&found);
let upper = dir.path().join("HOSTBIN");
if std::fs::metadata(&upper).is_err() {
return;
}
assert!(same_executable(&upper, &found, id));
}
}
}
#[cfg(windows)]
pub(crate) use windows_impl::{verify_pid_host, verify_pipe_host};
#[cfg(windows)]
mod windows_impl {
use super::*;
use crate::peer::{verify_pipe_server_is_current_user, PeerError, PeerIdentity};
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
use std::os::windows::io::{AsRawHandle, RawHandle};
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
const SYNCHRONIZE: u32 = 0x0010_0000;
const WAIT_TIMEOUT: u32 = 258;
const ERROR_INVALID_PARAMETER: i32 = 87;
const LONGEST_PATH: usize = 32_768;
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[allow(dead_code)]
struct FileTime {
low: u32,
high: u32,
}
#[repr(C)]
#[derive(Default)]
#[allow(dead_code)]
struct ByHandleFileInformation {
attributes: u32,
creation: FileTime,
access: FileTime,
write: FileTime,
volume_serial: u32,
size_high: u32,
size_low: u32,
links: u32,
index_high: u32,
index_low: u32,
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetNamedPipeServerProcessId(pipe: RawHandle, pid: *mut u32) -> i32;
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> RawHandle;
fn GetProcessTimes(
process: RawHandle,
creation: *mut FileTime,
exit: *mut FileTime,
kernel: *mut FileTime,
user: *mut FileTime,
) -> i32;
fn QueryFullProcessImageNameW(
process: RawHandle,
flags: u32,
name: *mut u16,
size: *mut u32,
) -> i32;
fn WaitForSingleObject(handle: RawHandle, milliseconds: u32) -> u32;
fn CloseHandle(handle: RawHandle) -> i32;
fn GetFileInformationByHandle(file: RawHandle, info: *mut ByHandleFileInformation) -> i32;
}
struct Process(RawHandle);
impl Drop for Process {
fn drop(&mut self) {
unsafe { CloseHandle(self.0) };
}
}
impl Process {
fn open(pid: u32) -> io::Result<Process> {
let handle =
unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid) };
if handle.is_null() {
return Err(io::Error::last_os_error());
}
Ok(Process(handle))
}
fn created(&self) -> io::Result<FileTime> {
let mut times = [FileTime::default(); 4];
let [creation, exit, kernel, user] = &mut times;
if unsafe { GetProcessTimes(self.0, creation, exit, kernel, user) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(times[0])
}
fn running(&self) -> bool {
unsafe { WaitForSingleObject(self.0, 0) == WAIT_TIMEOUT }
}
fn image(&self) -> io::Result<PathBuf> {
let mut buffer = vec![0u16; LONGEST_PATH];
let mut length = buffer.len() as u32;
if unsafe { QueryFullProcessImageNameW(self.0, 0, buffer.as_mut_ptr(), &mut length) }
== 0
{
return Err(io::Error::last_os_error());
}
Ok(PathBuf::from(OsString::from_wide(
&buffer[..length as usize],
)))
}
}
fn server_pid(pipe: RawHandle) -> io::Result<u32> {
let mut pid = 0u32;
if unsafe { GetNamedPipeServerProcessId(pipe, &mut pid) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(pid)
}
pub(super) fn file_id(path: &Path) -> Option<(u32, u64)> {
let file = std::fs::File::open(path).ok()?;
let mut info = ByHandleFileInformation::default();
if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
return None;
}
Some((
info.volume_serial,
(u64::from(info.index_high) << 32) | u64::from(info.index_low),
))
}
pub(super) fn same_executable(
expected: &Path,
found: &Path,
found_id: Option<(u32, u64)>,
) -> bool {
let Ok(expected) = std::fs::canonicalize(expected) else {
return false;
};
let lower = |path: &Path| path.to_string_lossy().to_lowercase();
if lower(&expected) == lower(found) {
return true;
}
found_id.is_some() && file_id(&expected) == found_id
}
fn plain(path: PathBuf) -> PathBuf {
let text = path.to_string_lossy();
match text.strip_prefix(r"\\?\UNC\") {
Some(rest) => PathBuf::from(format!(r"\\{rest}")),
None => match text.strip_prefix(r"\\?\") {
Some(rest) => PathBuf::from(rest),
None => path,
},
}
}
pub(crate) fn verify_pipe_host(
pipe: RawHandle,
expect: &HostExpectation,
) -> Result<HostProcess, HostConnectError> {
let refuse = |pid: Option<u32>, exe: Option<PathBuf>, reason: HostIdentityReason| {
HostConnectError::Identity(HostIdentityError {
expected: expect.executables.clone(),
found_pid: pid,
found_executable: exe,
reason,
})
};
if expect.executables.is_empty() {
return Err(refuse(None, None, HostIdentityReason::NoExpectation));
}
let first = match verify_pipe_server_is_current_user(pipe) {
Ok(id) => id,
Err(e) => {
if e.peer_gone() {
return Err(HostConnectError::Io(io::Error::from(e)));
}
let pid = match &e {
PeerError::Mismatch(id) => id.pid,
_ => None,
};
return Err(refuse(pid, None, HostIdentityReason::Query(e.to_string())));
}
};
let Some(pid) = first.pid else {
return Err(refuse(None, None, HostIdentityReason::PidUnavailable));
};
let unreadable = |what: &str, e: io::Error| {
refuse(
Some(pid),
None,
HostIdentityReason::Unreadable(format!("{what}: {e}")),
)
};
let changed = |why: &str| {
refuse(
Some(pid),
None,
HostIdentityReason::HostChanged(why.to_string()),
)
};
let process = match Process::open(pid) {
Ok(process) => process,
Err(e) if e.raw_os_error() == Some(ERROR_INVALID_PARAMETER) => {
return Err(changed("it exited before it could be opened"))
}
Err(e) => return Err(unreadable("open process", e)),
};
let before = process
.created()
.map_err(|e| unreadable("creation time", e))?;
let image = process
.image()
.map_err(|e| unreadable("executable path", e))?;
let after = process
.created()
.map_err(|e| unreadable("creation time", e))?;
if before != after {
return Err(changed(
"its creation time differed before and after its executable path was read",
));
}
if !process.running() {
return Err(changed("it exited while it was being identified"));
}
match server_pid(pipe) {
Ok(again) if again == pid => {}
_ => return Err(changed("the pipe no longer reports the same process id")),
}
let found = std::fs::canonicalize(&image).unwrap_or(image);
let id = file_id(&found);
if !expect
.executables
.iter()
.any(|expected| same_executable(expected, &found, id))
{
return Err(refuse(
Some(pid),
Some(plain(found)),
HostIdentityReason::ExecutableMismatch,
));
}
let host = HostProcess {
pid,
uid: None,
sid: first.sid,
executable: plain(found),
audit_token: None,
};
if let Some(verifier) = &expect.verifier {
if let Err(why) = verifier(&host) {
return Err(refuse(
Some(pid),
Some(host.executable.clone()),
HostIdentityReason::Refused(why),
));
}
}
drop(process);
Ok(host)
}
pub(super) fn start_marker_of(pid: u32) -> Option<(u64, u64)> {
let t = Process::open(pid).ok()?.created().ok()?;
Some(((u64::from(t.high) << 32) | u64::from(t.low), 0))
}
pub(crate) fn verify_pid_host(
peer: &PeerIdentity,
expect: &HostExpectation,
) -> Result<HostProcess, HostIdentityError> {
let refuse = |pid: Option<u32>, exe: Option<PathBuf>, reason: HostIdentityReason| {
HostIdentityError {
expected: expect.executables.clone(),
found_pid: pid,
found_executable: exe,
reason,
}
};
if expect.executables.is_empty() {
return Err(refuse(None, None, HostIdentityReason::NoExpectation));
}
let Some(pid) = peer.pid else {
return Err(refuse(None, None, HostIdentityReason::PidUnavailable));
};
let Some(sid) = peer.sid.clone() else {
return Err(refuse(
Some(pid),
None,
HostIdentityReason::Query("no user SID was recorded for the peer".to_string()),
));
};
let unreadable = |what: &str, e: io::Error| {
refuse(
Some(pid),
None,
HostIdentityReason::Unreadable(format!("{what}: {e}")),
)
};
let changed = |why: &str| {
refuse(
Some(pid),
None,
HostIdentityReason::HostChanged(why.to_string()),
)
};
let process = match Process::open(pid) {
Ok(process) => process,
Err(e) if e.raw_os_error() == Some(ERROR_INVALID_PARAMETER) => {
return Err(changed("it exited before it could be opened"))
}
Err(e) => return Err(unreadable("open process", e)),
};
let before = process
.created()
.map_err(|e| unreadable("creation time", e))?;
let marker = ((u64::from(before.high) << 32) | u64::from(before.low), 0);
if peer.started != Some(marker) {
return Err(changed(concat!(
"the process is not the one that connected (its creation time differs ",
"from the one recorded at connect, or none was recorded)"
)));
}
let image = process
.image()
.map_err(|e| unreadable("executable path", e))?;
let after = process
.created()
.map_err(|e| unreadable("creation time", e))?;
if before != after {
return Err(changed(
"its creation time differed before and after its executable path was read",
));
}
if !process.running() {
return Err(changed("it exited while it was being identified"));
}
let found = std::fs::canonicalize(&image).unwrap_or(image);
let id = file_id(&found);
if !expect
.executables
.iter()
.any(|expected| same_executable(expected, &found, id))
{
return Err(refuse(
Some(pid),
Some(plain(found)),
HostIdentityReason::ExecutableMismatch,
));
}
let host = HostProcess {
pid,
uid: None,
sid: Some(sid),
executable: plain(found),
audit_token: None,
};
if let Some(verifier) = &expect.verifier {
if let Err(why) = verifier(&host) {
return Err(refuse(
Some(pid),
Some(host.executable.clone()),
HostIdentityReason::Refused(why),
));
}
}
drop(process);
Ok(host)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_same_file_matches_by_path_in_any_case_and_by_identity_through_a_hard_link() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("HostBin.exe");
std::fs::write(&real, b"binary").unwrap();
let found = std::fs::canonicalize(&real).unwrap();
let id = file_id(&found);
assert!(id.is_some());
assert!(same_executable(&real, &found, id));
assert!(same_executable(&dir.path().join("hostbin.EXE"), &found, id));
assert!(same_executable(
&dir.path().join("hostbin.EXE"),
&found,
None
));
let hard = dir.path().join("hard.exe");
std::fs::hard_link(&real, &hard).unwrap();
assert!(same_executable(&hard, &found, id), "same file, other name");
assert!(!same_executable(&hard, &found, None));
let copy = dir.path().join("copy.exe");
std::fs::copy(&real, ©).unwrap();
assert!(!same_executable(©, &found, id));
assert!(!same_executable(
&dir.path().join("missing.exe"),
&found,
id
));
}
#[test]
fn this_process_is_described_by_its_own_handle() {
let process = Process::open(std::process::id()).unwrap();
assert!(process.running());
assert_eq!(process.created().unwrap(), process.created().unwrap());
let image = std::fs::canonicalize(process.image().unwrap()).unwrap();
let me = std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap();
assert!(same_executable(&me, &image, file_id(&image)));
assert!(!plain(image).to_string_lossy().starts_with(r"\\?\"));
}
#[test]
fn a_process_id_nothing_holds_cannot_be_opened() {
assert!(Process::open(0xFFFF_FFF3).is_err());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_file_name_is_one_component() {
assert!(is_single_component(OsStr::new("mailright")));
assert!(is_single_component(OsStr::new("mailright.exe")));
assert!(!is_single_component(OsStr::new("")));
assert!(!is_single_component(OsStr::new("..")));
assert!(!is_single_component(OsStr::new("a/b")));
assert!(!is_single_component(OsStr::new("/abs")));
}
#[test]
fn install_relative_paths_are_derived_from_where_the_engine_lives() {
let engine = Path::new("/Applications/MailRight.app/Contents/Resources/bin/engine");
assert_eq!(
bundle_main(engine, OsStr::new("mailright")),
Some(PathBuf::from(
"/Applications/MailRight.app/Contents/MacOS/mailright"
))
);
assert_eq!(
up_and_join(engine, 1, Path::new("app")),
Some(PathBuf::from(
"/Applications/MailRight.app/Contents/Resources/bin/app"
))
);
assert_eq!(
up_and_join(engine, 3, Path::new("MacOS/mailright")),
Some(PathBuf::from(
"/Applications/MailRight.app/Contents/MacOS/mailright"
))
);
assert_eq!(
bundle_main(Path::new("/opt/app/bin/engine"), OsStr::new("x")),
None
);
assert_eq!(up_and_join(engine, 0, Path::new("x")), None);
assert_eq!(up_and_join(engine, 1, Path::new("/etc/passwd")), None);
#[cfg(windows)]
for escape in [r"\Windows\System32\cmd.exe", "C:cmd.exe", r"C:\cmd.exe"] {
assert_eq!(up_and_join(engine, 1, Path::new(escape)), None, "{escape}");
}
assert_eq!(up_and_join(Path::new("engine"), 5, Path::new("x")), None);
}
#[test]
fn helpers_resolve_against_the_running_test_binary() {
let exe = std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap();
let sibling = HostExpectation::sibling_executable("host-binary").unwrap();
assert_eq!(
sibling.executables(),
[exe.parent().unwrap().join("host-binary")]
);
assert!(HostExpectation::sibling_executable("a/b").is_err());
assert!(HostExpectation::sibling_executable("..").is_err());
assert!(HostExpectation::bundle_executable("x/y").is_err());
let up2 = HostExpectation::relative_to_current_exe(2, "bin/host").unwrap();
assert_eq!(
up2.executables(),
[exe.parent().unwrap().parent().unwrap().join("bin/host")]
);
assert!(HostExpectation::relative_to_current_exe(0, "x").is_err());
}
#[test]
fn the_error_says_what_was_expected_and_what_was_found() {
let e = HostIdentityError {
expected: vec![PathBuf::from("/a/app"), PathBuf::from("/b/app")],
found_pid: Some(42),
found_executable: Some(PathBuf::from("/tmp/evil")),
reason: HostIdentityReason::ExecutableMismatch,
};
let text = e.to_string();
assert!(
text.contains("pid 42") && text.contains("/tmp/evil"),
"{text}"
);
assert!(text.contains("/a/app or /b/app"), "{text}");
let none = HostIdentityError {
expected: Vec::new(),
found_pid: None,
found_executable: None,
reason: HostIdentityReason::NoExpectation,
};
assert!(none.to_string().contains("none supplied"));
}
#[test]
fn a_refusal_collapses_to_a_non_retryable_wire_error() {
let e = HostIdentityError {
expected: vec![PathBuf::from("/a/app")],
found_pid: Some(7),
found_executable: None,
reason: HostIdentityReason::PidUnavailable,
};
let wire: crate::ServiceError = e.clone().into();
assert_eq!(wire.code, crate::ERROR_HOST_IDENTITY);
assert!(!wire.retryable);
assert!(wire.message.contains("pid 7"));
let c: crate::ConnectError = e.into();
assert_eq!(c.into_service_error(), wire);
}
}