Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 52 additions & 1 deletion crates/ipcprims-peer/src/peer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -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]
Expand Down
107 changes: 105 additions & 2 deletions crates/ipcprims-transport/src/async_uds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()));
Expand Down
4 changes: 4 additions & 0 deletions crates/ipcprims-transport/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
//! the [`IpcStream`] type provided here.

pub mod error;
pub mod peer_evidence;
pub mod traits;

#[cfg(windows)]
Expand All @@ -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)]
Expand Down
Loading
Loading