From 809ee1a7f1d17d34a6a3f781586554a3e67ff87f Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Tue, 21 Jul 2026 15:49:27 -0300 Subject: [PATCH] drm: unit-test the bespoke _drm SCM_RIGHTS framing (review 6) The _drm wire format is hand-rolled (length prefix plus an fd bound to the frame first byte) because Framed/BytesCodec cannot carry ancillary data, so it had zero tests. Add pure-userspace coverage over a socketpair: - a control message round-trips with and without an attached fd, and the received fd refers to the same open file (a byte written into the source is read back through it) - a raw length-prefixed body (cursor / CPU-fallback path) round-trips byte-for-byte - a forged length prefix past the JSON cap is rejected at the prefix - surplus fds packed into one cmsg keep only the first and close the rest - a control message truncated past DRM_CMSG_CAP is rejected (MSG_CTRUNC), not consumed - peer_uid_from_fd reads the socket peer credential the producer-auth path relies on --- src/ipc.rs | 193 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/src/ipc.rs b/src/ipc.rs index a0cc4e9fe..476417c89 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -3510,3 +3510,196 @@ mod test { assert!(select_server_uid_for_user_main_ipc(&[501, 502], None, false).is_err()); } } + +// Pure-userspace coverage for the bespoke `_drm` SCM_RIGHTS framing (review 6). The wire format is +// hand-rolled (length prefix + an fd bound to the frame's first byte) because `Framed`/`BytesCodec` +// cannot carry ancillary data, so it gets direct tests over a socketpair instead of only live runs. +#[cfg(all(test, target_os = "linux", feature = "drm"))] +mod drm_conn_tests { + use super::*; + use hbb_common::libc; + use hbb_common::tokio::{self, io::AsyncWriteExt}; + use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; + + // A blocking pipe as a probe fd: (read end, write end). Both are CLOEXEC-agnostic OwnedFds. + fn pipe() -> (OwnedFd, OwnedFd) { + let mut fds = [0 as libc::c_int; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe() failed"); + unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) } + } + + // Raw sendmsg carrying `fds` in a single SCM_RIGHTS cmsg, used to forge the surplus-fd / MSG_CTRUNC + // case the safe API cannot express (it sends at most one). + unsafe fn send_with_fds(sock: libc::c_int, data: &[u8], fds: &[libc::c_int]) -> isize { + let mut iov = libc::iovec { + iov_base: data.as_ptr() as *mut libc::c_void, + iov_len: data.len(), + }; + let fdbytes = fds.len() * std::mem::size_of::(); + let space = libc::CMSG_SPACE(fdbytes as u32) as usize; + let mut cbuf = vec![0u8; space]; + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = space as _; + let cmsg = libc::CMSG_FIRSTHDR(&msg); + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(fdbytes as u32) as _; + std::ptr::copy_nonoverlapping(fds.as_ptr() as *const u8, libc::CMSG_DATA(cmsg), fdbytes); + libc::sendmsg(sock, &msg, 0) + } + + // A control message with no fd round-trips intact and reports no ancillary fd. + #[tokio::test] + async fn roundtrip_msg_no_fd() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + tx.send_msg(&Data::DrmFrame { width: 1920, height: 1080 }, None) + .await + .unwrap(); + let (data, fd) = rx.recv_msg().await.unwrap(); + assert!(matches!( + data, + Data::DrmFrame { + width: 1920, + height: 1080 + } + )); + assert!(fd.is_none(), "no fd was sent, none must be reported"); + } + + // An fd bound to a frame's first byte crosses via SCM_RIGHTS and refers to the SAME open file: a + // byte written into the original write end is readable through the received (dup'd) read end. + #[tokio::test] + async fn roundtrip_msg_with_fd_identity() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + let (rd, wr) = pipe(); + tx.send_msg(&Data::DrmFrame { width: 4, height: 4 }, Some(rd.as_fd())) + .await + .unwrap(); + let (_data, fd) = rx.recv_msg().await.unwrap(); + let recv_fd = fd.expect("an fd was attached, it must be received"); + let sentinel = [0xABu8]; + assert_eq!( + unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, + 1 + ); + let mut got = [0u8; 1]; + assert_eq!( + unsafe { libc::read(recv_fd.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, + 1 + ); + assert_eq!(got[0], 0xAB, "received fd must be the same pipe"); + } + + // A raw length-prefixed body (cursor / CPU-fallback path) round-trips byte-for-byte. + #[tokio::test] + async fn roundtrip_raw_body() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + let body = Bytes::from(vec![7u8; 5000]); + tx.send_raw(body.clone()).await.unwrap(); + let got = rx.next_raw().await.unwrap(); + assert_eq!(&got[..], &body[..]); + } + + // A forged length prefix past the JSON cap is rejected at the prefix, before any body allocation. + #[tokio::test] + async fn rejects_oversized_length_prefix() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let bogus = (MAX_DRM_JSON_BYTES as u32 + 1).to_be_bytes(); + a.write_all(&bogus).await.unwrap(); + let err = rx + .recv_msg() + .await + .err() + .expect("a length past the cap must be rejected"); + assert!( + err.to_string().contains("exceeds cap"), + "unexpected error: {err}" + ); + } + + // A peer that packs more than one fd into a single SCM_RIGHTS cmsg (the safe API never does) must + // not smuggle extra fds into the consumer: drm_recvmsg keeps the FIRST and closes the rest. The + // frame otherwise decodes normally and the kept fd is the first one sent. (Two fds fit the control + // buffer thanks to cmsg alignment slack, so this exercises the surplus path, not truncation.) + #[tokio::test] + async fn surplus_fds_keep_only_the_first() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let (rd, wr) = pipe(); + let (rd2, _wr2) = pipe(); + let payload = serde_json::to_vec(&Data::DrmFrame { + width: 8, + height: 8, + }) + .unwrap(); + let prefix = (payload.len() as u32).to_be_bytes(); + let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &[rd.as_raw_fd(), rd2.as_raw_fd()]) }; + assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); + a.write_all(&payload).await.unwrap(); + let (data, fd) = rx.recv_msg().await.unwrap(); + assert!(matches!( + data, + Data::DrmFrame { + width: 8, + height: 8 + } + )); + let kept = fd.expect("the first surplus fd must be kept"); + let sentinel = [0x5Au8]; + assert_eq!( + unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, + 1 + ); + let mut got = [0u8; 1]; + assert_eq!( + unsafe { libc::read(kept.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, + 1 + ); + assert_eq!(got[0], 0x5A, "the kept fd must be the FIRST one sent"); + } + + // Enough fds to overflow the receiver's control buffer and force truncation. drm_recvmsg reads + // into a DRM_CMSG_CAP (64-byte) control buffer, which holds up to 12 fds (CMSG_LEN(48)=64); 16 fds + // need CMSG_LEN(64)=80 > 64, so the kernel sets MSG_CTRUNC and recv_msg must fail rather than + // proceed with silently dropped fd(s). + #[tokio::test] + async fn rejects_truncated_control_message() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let (rd, _wr) = pipe(); + let dups: Vec = (0..16).map(|_| rd.try_clone().unwrap()).collect(); + let fds: Vec = dups.iter().map(|f| f.as_raw_fd()).collect(); + let prefix = 0u32.to_be_bytes(); // the fds ride the prefix read; CTRUNC fires before any body + let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &fds) }; + assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); + let err = rx + .recv_msg() + .await + .err() + .expect("a truncated control message must be rejected"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("truncat") || msg.contains("ctrunc"), + "unexpected error: {err}" + ); + } + + // SO_PEERCRED plumbing: over a socketpair both ends report the creating process euid, so the + // producer-auth path (connect_drm requires peer_uid == 0) reads a real uid rather than None. + #[test] + fn peer_uid_from_fd_reads_socket_peer() { + let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap(); + let euid = unsafe { libc::geteuid() }; + assert_eq!(peer_uid_from_fd(a.as_raw_fd()), Some(euid)); + } +}