diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f225dd..ec7fc4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Changed + +- macOS sync and async Unix streams now return peer uid/gid/pid from `getpeereid` and `LOCAL_PEERPID` when all three are available. Previously `peer_credentials()` returned `None` on macOS; it still returns `None` if the pid cannot be observed. Linux tuple behavior is unchanged. +- Added typed `peer_evidence()` to Rust streams and peers with source, optional pid, and explicit unavailable reason. Peer evidence is not authentication. Windows remains unavailable. + ## [0.2.5] — 2026-08-25 Patch release for TypeScript package interoperability and SchemaRegistry guidance. diff --git a/README.md b/README.md index e7e3d79..5d98b7e 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,8 @@ This exercises `echo`, `send`, `listen`, `info`, `doctor`, and `envinfo` with sc Cross-platform transport abstraction. Unix domain sockets on Linux/macOS and named pipes on Windows. On Windows sync transport, read/write timeouts are enforced with overlapped I/O and cancellation. +Connected Unix streams expose `peer_credentials()` as `(uid, gid, pid)` on Linux and macOS. On macOS it returns `None` unless both `getpeereid` and `LOCAL_PEERPID` provide all three values. The additive `peer_evidence()` accessor reports uid/gid provenance separately from optional pid provenance, including a reason when pid is unavailable; macOS retains uid/gid if only the pid lookup fails. A Linux pid of 0 is absent in the typed result while the legacy tuple is unchanged. Windows and unimplemented Unix targets report unsupported. Peer evidence is an observation, not authentication; callers decide whether it suffices for their policy. + ### ipcprims-frame The core value-add. Length-prefixed message framing with channel multiplexing. diff --git a/crates/ipcprims-peer/src/peer.rs b/crates/ipcprims-peer/src/peer.rs index 95672f0..f10393a 100644 --- a/crates/ipcprims-peer/src/peer.rs +++ b/crates/ipcprims-peer/src/peer.rs @@ -269,6 +269,11 @@ impl Peer { self.reader.get_ref().peer_credentials() } + /// Platform peer evidence, which is not an authentication decision. + pub fn peer_evidence(&self) -> ipcprims_transport::PeerEvidence { + self.reader.get_ref().peer_evidence() + } + fn send_control(&mut self, message: ControlMessage) -> Result<()> { let payload = serde_json::to_vec(&message)?; self.writer.send(CONTROL, &payload)?; @@ -847,15 +852,61 @@ mod tests { let (peer, _other) = peer_pair(config); let creds = peer.reader.get_ref().peer_credentials(); assert!(creds.is_some()); + let (uid, gid, pid) = creds.unwrap(); + let ipcprims_transport::PeerEvidence::Unix(evidence) = peer.peer_evidence() else { + panic!("expected Unix evidence"); + }; + assert_eq!( + (evidence.uid, evidence.gid, evidence.pid), + (uid, gid, (pid != 0).then_some(pid)) + ); + assert_eq!( + evidence.uid_gid_source, + ipcprims_transport::PeerEvidenceSource::SoPeercred + ); + assert_eq!( + evidence.pid_source, + (pid != 0).then_some(ipcprims_transport::PeerEvidenceSource::SoPeercred) + ); + } + + #[test] + #[cfg(target_os = "macos")] + fn peer_credentials_macos() { + let config = PeerConfig::default(); + let (peer, _other) = peer_pair(config); + let credentials = peer.peer_credentials().expect("macOS peer credentials"); + assert_eq!(credentials.2, std::process::id()); + let ipcprims_transport::PeerEvidence::Unix(evidence) = peer.peer_evidence() else { + panic!("expected Unix evidence"); + }; + assert_eq!( + (evidence.uid, evidence.gid, evidence.pid), + (credentials.0, credentials.1, Some(credentials.2)) + ); + assert_eq!( + evidence.uid_gid_source, + ipcprims_transport::PeerEvidenceSource::Getpeereid + ); + assert_eq!( + evidence.pid_source, + Some(ipcprims_transport::PeerEvidenceSource::LocalPeerpid) + ); } #[test] - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "macos")))] fn peer_credentials_non_linux() { let config = PeerConfig::default(); let (peer, _other) = peer_pair(config); let creds = peer.reader.get_ref().peer_credentials(); assert!(creds.is_none()); + assert_eq!( + peer.peer_evidence(), + ipcprims_transport::PeerEvidence::Unavailable { + reason: ipcprims_transport::PeerEvidenceUnavailableReason::UnsupportedPlatform, + } + ); } #[test] diff --git a/crates/ipcprims-transport/src/async_uds.rs b/crates/ipcprims-transport/src/async_uds.rs index 69af6ba..6cadc3f 100644 --- a/crates/ipcprims-transport/src/async_uds.rs +++ b/crates/ipcprims-transport/src/async_uds.rs @@ -12,6 +12,9 @@ use tokio::net::UnixListener; use tracing::{debug, info}; use crate::error::{Result, TransportError}; +use crate::peer_evidence::PeerEvidence; +#[cfg(not(target_os = "macos"))] +use crate::peer_evidence::PeerEvidenceUnavailableReason; /// An async IPC stream (Tokio). /// @@ -51,9 +54,11 @@ impl AsyncIpcStream { self.inner } - /// Get the credentials of the connected peer (Linux only). + /// Get the credentials of the connected peer (Linux or macOS). /// /// Returns `(uid, gid, pid)` via `SO_PEERCRED`, or `None` if unavailable. + /// Linux's historical tuple retains pid 0 across pid namespaces; use + /// [`Self::peer_evidence`] for an optional pid. #[cfg(target_os = "linux")] pub fn peer_credentials(&self) -> Option<(u32, u32, u32)> { use std::os::fd::AsRawFd; @@ -85,13 +90,46 @@ impl AsyncIpcStream { } } + /// Get the credentials of the connected peer (macOS). + /// + /// Returns `None` unless `getpeereid` and `LOCAL_PEERPID` both succeed + /// and report a usable pid. Peer credentials are not authentication. + #[cfg(target_os = "macos")] + pub fn peer_credentials(&self) -> Option<(u32, u32, u32)> { + crate::peer_evidence::legacy_macos_credentials(self.peer_evidence()) + } + /// Get the credentials of the connected peer. /// /// Returns `None` on platforms that do not expose peer credentials. - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub fn peer_credentials(&self) -> Option<(u32, u32, u32)> { None } + + /// Observe platform peer evidence without treating it as authentication. + pub fn peer_evidence(&self) -> PeerEvidence { + #[cfg(target_os = "linux")] + { + match self.peer_credentials() { + Some(credentials) => crate::peer_evidence::linux_peer_evidence(credentials), + None => PeerEvidence::Unavailable { + reason: PeerEvidenceUnavailableReason::QueryFailed, + }, + } + } + #[cfg(target_os = "macos")] + { + use std::os::fd::AsRawFd; + crate::peer_evidence::macos_peer_evidence(self.inner.as_raw_fd()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + PeerEvidence::Unavailable { + reason: PeerEvidenceUnavailableReason::UnsupportedPlatform, + } + } + } } impl std::fmt::Debug for AsyncIpcStream { @@ -292,6 +330,71 @@ mod tests { use super::*; + #[tokio::test] + #[cfg(target_os = "linux")] + async fn peer_credentials_linux_match_typed_evidence() { + let (client, server) = tokio::net::UnixStream::pair().unwrap(); + let server = AsyncIpcStream::new(server); + let (uid, gid, pid) = server.peer_credentials().expect("Linux SO_PEERCRED"); + let PeerEvidence::Unix(evidence) = server.peer_evidence() else { + panic!("expected Unix evidence"); + }; + assert_eq!( + (evidence.uid, evidence.gid, evidence.pid), + (uid, gid, (pid != 0).then_some(pid)) + ); + assert_eq!( + evidence.uid_gid_source, + crate::PeerEvidenceSource::SoPeercred + ); + assert_eq!( + evidence.pid_source, + (pid != 0).then_some(crate::PeerEvidenceSource::SoPeercred) + ); + drop(client); + } + #[cfg(target_os = "macos")] + use crate::peer_evidence::PeerEvidenceSource; + + #[tokio::test] + #[cfg(target_os = "macos")] + async fn peer_credentials_macos() { + use tokio::io::AsyncWriteExt; + let dir = std::env::temp_dir().join(format!("ip-a-{}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("peer.sock"); + let listener = tokio::net::UnixListener::bind(&path).unwrap(); + let child = crate::peer_evidence::tests::spawn_client(&path); + let (stream, _) = listener.accept().await.unwrap(); + let pid = child.id(); + let server = AsyncIpcStream::new(stream); + let credentials = server.peer_credentials(); + let evidence = server.peer_evidence(); + let mut stream = server.into_inner(); + stream.write_all(&[1]).await.unwrap(); + let (uid, gid) = + tokio::task::spawn_blocking(move || crate::peer_evidence::tests::client_ids(child)) + .await + .unwrap(); + let expected = (uid, gid, pid); + + assert_eq!(credentials, Some(expected)); + assert_eq!( + evidence, + PeerEvidence::Unix(crate::UnixPeerEvidence { + uid: expected.0, + gid: expected.1, + pid: Some(expected.2), + uid_gid_source: PeerEvidenceSource::Getpeereid, + pid_source: Some(PeerEvidenceSource::LocalPeerpid), + pid_unavailable_reason: None, + }) + ); + drop(listener); + std::fs::remove_file(path).unwrap(); + std::fs::remove_dir(dir).unwrap(); + } + #[tokio::test] async fn bind_accept_connect() { let dir = std::env::temp_dir().join(format!("ipcprims-async-{}", std::process::id())); diff --git a/crates/ipcprims-transport/src/lib.rs b/crates/ipcprims-transport/src/lib.rs index 2c501a2..90337fa 100644 --- a/crates/ipcprims-transport/src/lib.rs +++ b/crates/ipcprims-transport/src/lib.rs @@ -8,6 +8,7 @@ //! the [`IpcStream`] type provided here. pub mod error; +pub mod peer_evidence; pub mod traits; #[cfg(windows)] @@ -16,6 +17,9 @@ pub mod npipes; pub mod uds; pub use error::{Result, TransportError}; +pub use peer_evidence::{ + PeerEvidence, PeerEvidenceSource, PeerEvidenceUnavailableReason, UnixPeerEvidence, +}; pub use traits::IpcStream; #[cfg(windows)] diff --git a/crates/ipcprims-transport/src/peer_evidence.rs b/crates/ipcprims-transport/src/peer_evidence.rs new file mode 100644 index 0000000..7347c3c --- /dev/null +++ b/crates/ipcprims-transport/src/peer_evidence.rs @@ -0,0 +1,285 @@ +//! Platform-reported observations about a connected peer. +//! +//! Peer evidence is not authentication. Applications decide whether the +//! available observations are sufficient for their own authorization policy. + +/// The platform facility that supplied Unix peer evidence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PeerEvidenceSource { + /// Linux `SO_PEERCRED`. + SoPeercred, + /// macOS `getpeereid` supplied uid/gid. + Getpeereid, + /// macOS `LOCAL_PEERPID` supplied pid. + LocalPeerpid, +} + +/// Why peer evidence could not be observed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PeerEvidenceUnavailableReason { + /// This platform does not yet have a peer-evidence implementation. + UnsupportedPlatform, + /// The platform did not provide peer credentials for this stream. + QueryFailed, + /// A pid lookup succeeded but returned an unusable pid or size. + InvalidPid, +} + +/// Unix peer credentials and the provenance of each observed value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct UnixPeerEvidence { + pub uid: u32, + pub gid: u32, + pub pid: Option, + /// Source of the uid and gid pair. + pub uid_gid_source: PeerEvidenceSource, + /// Source of the pid, when present. + pub pid_source: Option, + /// Explanation when no usable pid was observed. + pub pid_unavailable_reason: Option, +} + +/// Evidence observed from the connected peer, or an explicit unavailable reason. +/// +/// This is an observation, not an authentication or authorization decision. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PeerEvidence { + /// Unix credentials. A pid can be unavailable, including Linux pid 0 + /// reported across pid namespaces. + Unix(UnixPeerEvidence), + /// No trustworthy platform evidence was available. + Unavailable { + reason: PeerEvidenceUnavailableReason, + }, +} + +#[cfg(target_os = "linux")] +pub(crate) fn linux_peer_evidence((uid, gid, pid): (u32, u32, u32)) -> PeerEvidence { + PeerEvidence::Unix(UnixPeerEvidence { + uid, + gid, + pid: (pid != 0).then_some(pid), + uid_gid_source: PeerEvidenceSource::SoPeercred, + pid_source: (pid != 0).then_some(PeerEvidenceSource::SoPeercred), + pid_unavailable_reason: (pid == 0).then_some(PeerEvidenceUnavailableReason::InvalidPid), + }) +} + +#[cfg(target_os = "macos")] +pub(crate) fn legacy_macos_credentials(evidence: PeerEvidence) -> Option<(u32, u32, u32)> { + match evidence { + PeerEvidence::Unix(UnixPeerEvidence { + uid, + gid, + pid: Some(pid), + .. + }) => Some((uid, gid, pid)), + _ => None, + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn macos_peer_evidence(fd: std::os::fd::RawFd) -> PeerEvidence { + let mut uid: libc::uid_t = 0; + let mut gid: libc::gid_t = 0; + + // SAFETY: uid and gid are valid writable pointers; getpeereid only reads fd. + if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } != 0 { + return PeerEvidence::Unavailable { + reason: PeerEvidenceUnavailableReason::QueryFailed, + }; + } + + let mut pid: libc::pid_t = 0; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: pid and len are writable and sized for a pid_t; getsockopt only reads fd. + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + (&mut pid as *mut libc::pid_t).cast::(), + &mut len, + ) + }; + macos_observation(uid, gid, rc == 0, len as usize, pid) +} + +#[cfg(target_os = "macos")] +fn macos_observation( + uid: u32, + gid: u32, + query_ok: bool, + len: usize, + pid: libc::pid_t, +) -> PeerEvidence { + let reason = if !query_ok { + Some(PeerEvidenceUnavailableReason::QueryFailed) + } else if len != std::mem::size_of::() || pid <= 0 { + Some(PeerEvidenceUnavailableReason::InvalidPid) + } else { + None + }; + PeerEvidence::Unix(UnixPeerEvidence { + uid, + gid, + pid: reason.is_none().then_some(pid as u32), + uid_gid_source: PeerEvidenceSource::Getpeereid, + pid_source: reason.is_none().then_some(PeerEvidenceSource::LocalPeerpid), + pid_unavailable_reason: reason, + }) +} + +#[cfg(all(test, target_os = "macos"))] +pub(crate) mod tests { + use super::*; + use crate::IpcStream; + use std::io::{Read, Write}; + use std::process::{Child, Command, Stdio}; + + pub(crate) fn spawn_client(path: &std::path::Path) -> Child { + Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "peer_evidence::tests::macos_child_client", + "--nocapture", + ]) + .env("IPCPRIMS_TEST_PEER_SOCKET", path) + .stdout(Stdio::piped()) + .spawn() + .unwrap() + } + + pub(crate) fn client_ids(child: Child) -> (u32, u32) { + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let text = String::from_utf8(output.stdout).unwrap(); + let fields = text + .split("PEER_IDS=") + .nth(1) + .expect("client effective IDs"); + let mut fields = fields.split_whitespace().next().unwrap().split(','); + ( + fields.next().unwrap().parse().unwrap(), + fields.next().unwrap().parse().unwrap(), + ) + } + + #[test] + fn macos_child_client() { + let Ok(path) = std::env::var("IPCPRIMS_TEST_PEER_SOCKET") else { + return; + }; + let mut stream = std::os::unix::net::UnixStream::connect(path).unwrap(); + println!("PEER_IDS={},{}", unsafe { libc::geteuid() }, unsafe { + libc::getegid() + }); + let mut ack = [0]; + stream.read_exact(&mut ack).unwrap(); + } + + #[test] + fn macos_connected_stream_reports_current_process() { + let dir = std::env::temp_dir().join(format!("ip-s-{}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("peer.sock"); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let child = spawn_client(&path); + let (mut stream, _) = listener.accept().unwrap(); + let pid = child.id(); + let server = IpcStream::from_unix(stream.try_clone().unwrap()); + let credentials = server.peer_credentials(); + let evidence = server.peer_evidence(); + stream.write_all(&[1]).unwrap(); + let (uid, gid) = client_ids(child); + let expected = (uid, gid, pid); + + assert_eq!(credentials, Some(expected)); + assert_eq!( + evidence, + PeerEvidence::Unix(UnixPeerEvidence { + uid: expected.0, + gid: expected.1, + pid: Some(expected.2), + uid_gid_source: PeerEvidenceSource::Getpeereid, + pid_source: Some(PeerEvidenceSource::LocalPeerpid), + pid_unavailable_reason: None, + }) + ); + drop(listener); + std::fs::remove_file(path).unwrap(); + std::fs::remove_dir(dir).unwrap(); + } + + #[test] + fn invalid_fd_has_explicit_unavailable_reason() { + assert_eq!( + macos_peer_evidence(-1), + PeerEvidence::Unavailable { + reason: PeerEvidenceUnavailableReason::QueryFailed, + } + ); + } + + #[test] + fn missing_pid_does_not_become_a_legacy_tuple() { + assert_eq!( + legacy_macos_credentials(PeerEvidence::Unix(UnixPeerEvidence { + uid: 501, + gid: 20, + pid: None, + uid_gid_source: PeerEvidenceSource::Getpeereid, + pid_source: None, + pid_unavailable_reason: Some(PeerEvidenceUnavailableReason::QueryFailed), + })), + None + ); + assert_eq!( + macos_observation(501, 20, false, 4, 0), + PeerEvidence::Unix(UnixPeerEvidence { + uid: 501, + gid: 20, + pid: None, + uid_gid_source: PeerEvidenceSource::Getpeereid, + pid_source: None, + pid_unavailable_reason: Some(PeerEvidenceUnavailableReason::QueryFailed), + }) + ); + assert_eq!( + legacy_macos_credentials(macos_observation(501, 20, true, 4, 0)), + None + ); + assert_eq!( + legacy_macos_credentials(macos_observation(501, 20, true, 0, 123)), + None + ); + } +} + +#[cfg(all(test, target_os = "linux"))] +mod linux_tests { + use super::*; + + #[test] + fn pid_zero_is_absent_only_in_typed_evidence() { + assert_eq!( + linux_peer_evidence((1000, 1000, 0)), + PeerEvidence::Unix(UnixPeerEvidence { + uid: 1000, + gid: 1000, + pid: None, + uid_gid_source: PeerEvidenceSource::SoPeercred, + pid_source: None, + pid_unavailable_reason: Some(PeerEvidenceUnavailableReason::InvalidPid), + }) + ); + } +} diff --git a/crates/ipcprims-transport/src/traits.rs b/crates/ipcprims-transport/src/traits.rs index b4e4625..6b93a2e 100644 --- a/crates/ipcprims-transport/src/traits.rs +++ b/crates/ipcprims-transport/src/traits.rs @@ -3,6 +3,9 @@ use std::io::{Read, Write}; use crate::error::Result; #[cfg(windows)] use crate::npipes::NamedPipeStream; +use crate::peer_evidence::PeerEvidence; +#[cfg(not(target_os = "macos"))] +use crate::peer_evidence::PeerEvidenceUnavailableReason; /// A connected IPC stream — implements Read + Write. /// @@ -108,9 +111,11 @@ impl IpcStream { } } - /// Get the credentials of the connected peer (Linux only). + /// Get the credentials of the connected peer (Linux or macOS). /// /// Returns `(uid, gid, pid)` via `SO_PEERCRED`, or `None` if unavailable. + /// Linux's historical tuple retains pid 0 across pid namespaces; use + /// [`Self::peer_evidence`] for an optional pid. #[cfg(target_os = "linux")] pub fn peer_credentials(&self) -> Option<(u32, u32, u32)> { use std::os::fd::AsRawFd; @@ -145,13 +150,47 @@ impl IpcStream { } } + /// Get the credentials of the connected peer (macOS). + /// + /// Returns `None` unless `getpeereid` and `LOCAL_PEERPID` both succeed + /// and report a usable pid. Peer credentials are not authentication. + #[cfg(target_os = "macos")] + pub fn peer_credentials(&self) -> Option<(u32, u32, u32)> { + crate::peer_evidence::legacy_macos_credentials(self.peer_evidence()) + } + /// Get the credentials of the connected peer. /// /// Returns `None` on platforms that do not expose peer credentials. - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub fn peer_credentials(&self) -> Option<(u32, u32, u32)> { None } + + /// Observe platform peer evidence without treating it as authentication. + pub fn peer_evidence(&self) -> PeerEvidence { + #[cfg(target_os = "linux")] + { + match self.peer_credentials() { + Some(credentials) => crate::peer_evidence::linux_peer_evidence(credentials), + None => PeerEvidence::Unavailable { + reason: PeerEvidenceUnavailableReason::QueryFailed, + }, + } + } + #[cfg(target_os = "macos")] + { + use std::os::fd::AsRawFd; + let IpcStreamInner::Unix(stream) = &self.inner; + crate::peer_evidence::macos_peer_evidence(stream.as_raw_fd()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + PeerEvidence::Unavailable { + reason: PeerEvidenceUnavailableReason::UnsupportedPlatform, + } + } + } } #[cfg_attr(not(unix), allow(unused_variables))]