From b510986e4d4c719cc70456be9a438f41632acde3 Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Thu, 27 Aug 2026 13:25:22 +0530 Subject: [PATCH] feat(core-rs): add secure local transport seam --- PLAN.md | 5 +- apps/headless-rs/src/lib.rs | 1 + apps/headless-rs/src/transport.rs | 180 ++++++++++ apps/headless-rs/src/transport/unix.rs | 403 ++++++++++++++++++++++ apps/headless-rs/tests/transport_tests.rs | 378 ++++++++++++++++++++ docs/ROADMAP.md | 3 + docs/roadmap/architecture-decisions.md | 7 + docs/roadmap/improvements-backlog.md | 15 + 8 files changed, 990 insertions(+), 2 deletions(-) create mode 100644 apps/headless-rs/src/transport.rs create mode 100644 apps/headless-rs/src/transport/unix.rs create mode 100644 apps/headless-rs/tests/transport_tests.rs diff --git a/PLAN.md b/PLAN.md index c4cec3f..b0da20b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -58,9 +58,10 @@ we port only the shared core to Rust, never the whole product. - Step 2: adapter ruled out. Per architecture decision §21, the shared core is being ported to Rust instead: `apps/headless-rs` now carries the full protocol layer (39 commands, strict validation, navigation boundary, - artifact rules, 1 MiB codec) with 10 tests mirroring the Swift suite's + artifact rules, 1 MiB codec) plus the platform-neutral control-transport + seam and secure Unix backend from #140. The Rust suite mirrors the Swift security-critical cases, and CI builds it natively on Linux, macOS, and - Windows. Next increments: transport, CLI parser, Chromium CDP host. + Windows. Next increments: Windows named pipes, CLI parser, Chromium CDP host. - Step 3: WSL2 install documented in README; macOS/Linux packaging already shipped in the E-series work. Once the Rust core gains a host, Windows gets a real native story (winget/MSI) instead of WSL2. diff --git a/apps/headless-rs/src/lib.rs b/apps/headless-rs/src/lib.rs index be77c8d..5e70b59 100644 --- a/apps/headless-rs/src/lib.rs +++ b/apps/headless-rs/src/lib.rs @@ -9,6 +9,7 @@ pub mod error; pub mod json; pub mod protocol; +pub mod transport; pub mod url; pub mod validate; diff --git a/apps/headless-rs/src/transport.rs b/apps/headless-rs/src/transport.rs new file mode 100644 index 0000000..1da6921 --- /dev/null +++ b/apps/headless-rs/src/transport.rs @@ -0,0 +1,180 @@ +//! Local control transport primitives. +//! +//! Framing and request correlation are platform-independent. Platform +//! backends provide authenticated local connections without adding a network +//! listener. The Unix backend is implemented first; Windows named pipes plug +//! into the same traits in a later increment. + +use std::fmt; +use std::io::{self, Read, Write}; + +use crate::error::ValidationError; +use crate::protocol::{codec, CommandRequest, CommandResponse, UNKNOWN_REQUEST_IDENTIFIER}; +use crate::HEADLESS_MAXIMUM_MESSAGE_BYTES; + +#[cfg(unix)] +pub mod unix; + +#[derive(Debug)] +pub enum TransportError { + TimedOut, + ConnectionFailed, + ConnectionClosed, + MessageTooLarge, + InvalidRuntimeDirectory, + EndpointOutsideRuntimeDirectory, + AlreadyRunning, + PeerDenied, + MismatchedResponse, + Protocol(ValidationError), + Io { + operation: &'static str, + source: io::Error, + }, +} + +impl TransportError { + fn from_io(operation: &'static str, error: io::Error) -> Self { + match error.kind() { + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => Self::TimedOut, + io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::ConnectionReset + | io::ErrorKind::NotConnected + | io::ErrorKind::UnexpectedEof + | io::ErrorKind::WriteZero => Self::ConnectionClosed, + _ => Self::Io { + operation, + source: error, + }, + } + } +} + +impl fmt::Display for TransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TimedOut => write!(f, "Headless host did not respond before the deadline"), + Self::ConnectionFailed => write!(f, "Headless host is not running"), + Self::ConnectionClosed => write!(f, "Headless host closed the connection"), + Self::MessageTooLarge => write!(f, "Headless host message exceeded the size limit"), + Self::InvalidRuntimeDirectory => { + write!( + f, + "Local runtime directory is not private to the current user" + ) + } + Self::EndpointOutsideRuntimeDirectory => { + write!( + f, + "Local endpoint must be inside the private runtime directory" + ) + } + Self::AlreadyRunning => write!( + f, + "Another Headless host is already using the local endpoint" + ), + Self::PeerDenied => write!(f, "Local transport peer is not authorized"), + Self::MismatchedResponse => write!(f, "Headless host replied to a different request"), + Self::Protocol(error) => write!(f, "{error}"), + Self::Io { operation, source } => { + write!(f, "Local transport {operation} failed: {source}") + } + } + } +} + +impl std::error::Error for TransportError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Protocol(error) => Some(error), + Self::Io { source, .. } => Some(source), + _ => None, + } + } +} + +impl From for TransportError { + fn from(value: ValidationError) -> Self { + Self::Protocol(value) + } +} + +pub trait ControlConnection: Read + Write + Send {} + +impl ControlConnection for T {} + +pub trait ControlListener { + type Connection: ControlConnection; + + fn accept(&self) -> Result; +} + +/// Read exactly one newline-delimited frame without allowing its buffer to +/// grow beyond the wire cap. Bytes after the first newline are irrelevant to +/// the one-request-per-connection protocol and are intentionally ignored. +pub fn read_frame(reader: &mut R) -> Result, TransportError> { + let mut frame = Vec::with_capacity(8_192); + let mut chunk = [0_u8; 8_192]; + + loop { + let count = loop { + match reader.read(&mut chunk) { + Ok(count) => break count, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(TransportError::from_io("read", error)), + } + }; + if count == 0 { + return Err(TransportError::ConnectionClosed); + } + + if let Some(newline) = chunk[..count].iter().position(|byte| *byte == b'\n') { + let frame_end = newline + 1; + if frame.len() + frame_end > HEADLESS_MAXIMUM_MESSAGE_BYTES { + return Err(TransportError::MessageTooLarge); + } + frame.extend_from_slice(&chunk[..frame_end]); + return Ok(frame); + } + + if frame.len() + count >= HEADLESS_MAXIMUM_MESSAGE_BYTES { + return Err(TransportError::MessageTooLarge); + } + frame.extend_from_slice(&chunk[..count]); + } +} + +pub fn write_frame(writer: &mut W, frame: &[u8]) -> Result<(), TransportError> { + if frame.len() > HEADLESS_MAXIMUM_MESSAGE_BYTES { + return Err(TransportError::MessageTooLarge); + } + writer + .write_all(frame) + .map_err(|error| TransportError::from_io("write", error)) +} + +/// Exchange one validated request and correlated response on an authenticated +/// local connection. +pub fn exchange( + connection: &mut C, + request: &CommandRequest, +) -> Result { + request.validate()?; + exchange_validated(connection, request) +} + +pub(crate) fn exchange_validated( + connection: &mut C, + request: &CommandRequest, +) -> Result { + let request_frame = codec::encode_line(request)?; + write_frame(connection, &request_frame)?; + + let response_frame = read_frame(connection)?; + let response: CommandResponse = codec::decode_line(&response_frame)?; + if response.id != request.id && response.id != UNKNOWN_REQUEST_IDENTIFIER { + return Err(TransportError::MismatchedResponse); + } + Ok(response) +} diff --git a/apps/headless-rs/src/transport/unix.rs b/apps/headless-rs/src/transport/unix.rs new file mode 100644 index 0000000..1aa03a2 --- /dev/null +++ b/apps/headless-rs/src/transport/unix.rs @@ -0,0 +1,403 @@ +//! Secure Unix-domain socket backend for the control transport. + +use std::env; +use std::fs::{self, DirBuilder, FileType, Metadata, Permissions}; +use std::io; +use std::os::fd::{AsRawFd, RawFd}; +use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; + +use crate::protocol::{CommandRequest, CommandResponse}; +use crate::transport::{exchange_validated, ControlListener, TransportError}; + +const RUNTIME_DIRECTORY_MODE: u32 = 0o700; +const SOCKET_MODE: u32 = 0o600; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnixRuntime { + directory: PathBuf, + socket: PathBuf, +} + +impl UnixRuntime { + pub fn for_current_user() -> Self { + let directory = PathBuf::from(format!("/tmp/headless-{}", effective_user_id())); + let socket = env::var_os("HEADLESS_SOCKET") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| directory.join("host.sock")); + Self { directory, socket } + } + + pub fn new(directory: PathBuf, socket: PathBuf) -> Self { + Self { directory, socket } + } + + pub fn directory(&self) -> &Path { + &self.directory + } + + pub fn socket(&self) -> &Path { + &self.socket + } + + pub fn prepare_private_directory(&self) -> Result<(), TransportError> { + prepare_private_directory(&self.directory) + } + + fn validate_endpoint_parent(&self) -> Result<(), TransportError> { + if !is_direct_child(&self.socket, &self.directory) { + return Err(TransportError::EndpointOutsideRuntimeDirectory); + } + Ok(()) + } +} + +#[derive(Debug)] +pub struct UnixControlListener { + listener: UnixListener, + socket_path: PathBuf, + socket_identity: (u64, u64), +} + +impl UnixControlListener { + pub fn bind(runtime: &UnixRuntime) -> Result { + runtime.prepare_private_directory()?; + runtime.validate_endpoint_parent()?; + remove_stale_socket_if_safe(runtime.socket())?; + + let listener = UnixListener::bind(runtime.socket()) + .map_err(|error| map_bind_error(error, runtime.socket()))?; + if let Err(error) = + fs::set_permissions(runtime.socket(), Permissions::from_mode(SOCKET_MODE)) + { + let _ = fs::remove_file(runtime.socket()); + return Err(TransportError::Io { + operation: "socket permission setup", + source: error, + }); + } + let metadata = match fs::symlink_metadata(runtime.socket()) { + Ok(metadata) => metadata, + Err(source) => { + let _ = fs::remove_file(runtime.socket()); + return Err(TransportError::Io { + operation: "socket identity validation", + source, + }); + } + }; + if !metadata.file_type().is_socket() + || metadata.uid() != effective_user_id() + || metadata.mode() & 0o777 != SOCKET_MODE + { + let _ = fs::remove_file(runtime.socket()); + return Err(TransportError::InvalidRuntimeDirectory); + } + + Ok(Self { + listener, + socket_path: runtime.socket().to_path_buf(), + socket_identity: (metadata.dev(), metadata.ino()), + }) + } + + pub fn socket_path(&self) -> &Path { + &self.socket_path + } +} + +impl ControlListener for UnixControlListener { + type Connection = UnixStream; + + fn accept(&self) -> Result { + let (stream, _) = self.listener.accept().map_err(|error| TransportError::Io { + operation: "accept", + source: error, + })?; + authorize_peer(effective_user_id(), peer_user_id(&stream)?)?; + Ok(stream) + } +} + +impl Drop for UnixControlListener { + fn drop(&mut self) { + let Ok(metadata) = fs::symlink_metadata(&self.socket_path) else { + return; + }; + if metadata.file_type().is_socket() + && metadata.uid() == effective_user_id() + && (metadata.dev(), metadata.ino()) == self.socket_identity + { + let _ = fs::remove_file(&self.socket_path); + } + } +} + +#[derive(Debug, Clone)] +pub struct UnixControlClient { + socket_path: PathBuf, + timeout: Duration, +} + +impl UnixControlClient { + pub fn new(socket_path: PathBuf, timeout: Duration) -> Self { + Self { + socket_path, + timeout, + } + } + + pub fn send(&self, request: &CommandRequest) -> Result { + request.validate()?; + let mut stream = UnixStream::connect(&self.socket_path).map_err(|error| { + if matches!( + error.kind(), + io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound + ) { + TransportError::ConnectionFailed + } else { + TransportError::Io { + operation: "connect", + source: error, + } + } + })?; + stream + .set_read_timeout(Some(self.timeout)) + .map_err(|error| TransportError::Io { + operation: "read timeout configuration", + source: error, + })?; + stream + .set_write_timeout(Some(self.timeout)) + .map_err(|error| TransportError::Io { + operation: "write timeout configuration", + source: error, + })?; + exchange_validated(&mut stream, request) + } +} + +fn prepare_private_directory(path: &Path) -> Result<(), TransportError> { + match fs::symlink_metadata(path) { + Ok(metadata) => validate_private_directory(&metadata), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let mut builder = DirBuilder::new(); + builder.mode(RUNTIME_DIRECTORY_MODE); + match builder.create(path) { + Ok(()) => { + fs::set_permissions(path, Permissions::from_mode(RUNTIME_DIRECTORY_MODE)) + .map_err(|source| TransportError::Io { + operation: "runtime directory permission setup", + source, + })?; + let metadata = + fs::symlink_metadata(path).map_err(|source| TransportError::Io { + operation: "runtime directory validation", + source, + })?; + validate_private_directory(&metadata) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let metadata = + fs::symlink_metadata(path).map_err(|source| TransportError::Io { + operation: "runtime directory validation", + source, + })?; + validate_private_directory(&metadata) + } + Err(source) => Err(TransportError::Io { + operation: "runtime directory creation", + source, + }), + } + } + Err(source) => Err(TransportError::Io { + operation: "runtime directory validation", + source, + }), + } +} + +fn validate_private_directory(metadata: &Metadata) -> Result<(), TransportError> { + if !metadata.file_type().is_dir() + || metadata.uid() != effective_user_id() + || metadata.mode() & 0o077 != 0 + { + return Err(TransportError::InvalidRuntimeDirectory); + } + Ok(()) +} + +fn is_direct_child(path: &Path, parent: &Path) -> bool { + path.is_absolute() + && parent.is_absolute() + && !path + .components() + .any(|component| matches!(component, Component::ParentDir)) + && path.parent() == Some(parent) + && path.file_name().is_some() +} + +fn remove_stale_socket_if_safe(path: &Path) -> Result<(), TransportError> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(TransportError::Io { + operation: "existing socket validation", + source, + }) + } + }; + + if !is_socket(&metadata.file_type()) || metadata.uid() != effective_user_id() { + return Err(TransportError::InvalidRuntimeDirectory); + } + + match UnixStream::connect(path) { + Ok(_) => Err(TransportError::AlreadyRunning), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound + ) => + { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(TransportError::Io { + operation: "stale socket removal", + source, + }), + } + } + Err(source) => Err(TransportError::Io { + operation: "existing socket probe", + source, + }), + } +} + +fn is_socket(file_type: &FileType) -> bool { + file_type.is_socket() +} + +fn map_bind_error(error: io::Error, path: &Path) -> TransportError { + if error.kind() == io::ErrorKind::AddrInUse && path.exists() { + TransportError::AlreadyRunning + } else { + TransportError::Io { + operation: "bind", + source: error, + } + } +} + +fn authorize_peer(expected: u32, actual: u32) -> Result<(), TransportError> { + if expected == actual { + Ok(()) + } else { + Err(TransportError::PeerDenied) + } +} + +#[cfg(target_os = "linux")] +fn peer_user_id(stream: &UnixStream) -> Result { + #[repr(C)] + struct PeerCredentials { + pid: i32, + uid: u32, + gid: u32, + } + + const SOL_SOCKET: i32 = 1; + const SO_PEERCRED: i32 = 17; + let mut credentials = PeerCredentials { + pid: 0, + uid: 0, + gid: 0, + }; + let mut length = std::mem::size_of::() as u32; + let result = unsafe { + getsockopt( + stream.as_raw_fd(), + SOL_SOCKET, + SO_PEERCRED, + (&mut credentials as *mut PeerCredentials).cast(), + &mut length, + ) + }; + if result != 0 || length as usize != std::mem::size_of::() { + return Err(TransportError::Io { + operation: "peer credential check", + source: io::Error::last_os_error(), + }); + } + Ok(credentials.uid) +} + +#[cfg(target_os = "macos")] +fn peer_user_id(stream: &UnixStream) -> Result { + let mut uid = 0_u32; + let mut gid = 0_u32; + let result = unsafe { getpeereid(stream.as_raw_fd(), &mut uid, &mut gid) }; + if result != 0 { + return Err(TransportError::Io { + operation: "peer credential check", + source: io::Error::last_os_error(), + }); + } + Ok(uid) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn peer_user_id(_stream: &UnixStream) -> Result { + Err(TransportError::Io { + operation: "peer credential check", + source: io::Error::new(io::ErrorKind::Unsupported, "unsupported Unix platform"), + }) +} + +#[cfg(unix)] +fn effective_user_id() -> u32 { + unsafe { geteuid() } +} + +#[cfg(target_os = "linux")] +extern "C" { + fn getsockopt( + socket: RawFd, + level: i32, + option_name: i32, + option_value: *mut std::ffi::c_void, + option_length: *mut u32, + ) -> i32; +} + +#[cfg(target_os = "macos")] +extern "C" { + fn getpeereid(socket: RawFd, effective_uid: *mut u32, effective_gid: *mut u32) -> i32; +} + +extern "C" { + fn geteuid() -> u32; +} + +#[cfg(test)] +mod tests { + use super::authorize_peer; + use crate::transport::TransportError; + + #[test] + fn peer_authorization_fails_closed() { + authorize_peer(501, 501).unwrap(); + assert!(matches!( + authorize_peer(501, 502), + Err(TransportError::PeerDenied) + )); + } +} diff --git a/apps/headless-rs/tests/transport_tests.rs b/apps/headless-rs/tests/transport_tests.rs new file mode 100644 index 0000000..c9a3e3b --- /dev/null +++ b/apps/headless-rs/tests/transport_tests.rs @@ -0,0 +1,378 @@ +#![cfg(unix)] + +use std::collections::BTreeMap; +use std::fs::{self, DirBuilder, File, Permissions}; +use std::io::{self, Cursor, Read, Write}; +use std::os::unix::fs::{symlink, DirBuilderExt, MetadataExt, PermissionsExt}; +use std::os::unix::net::UnixListener; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread; +use std::time::Duration; + +use headless_protocol::protocol::{ + codec, request_with_id, CommandName, CommandRequest, CommandResponse, + UNKNOWN_REQUEST_IDENTIFIER, +}; +use headless_protocol::transport::unix::{UnixControlClient, UnixControlListener, UnixRuntime}; +use headless_protocol::transport::{ + exchange, read_frame, write_frame, ControlListener, TransportError, +}; +use headless_protocol::HEADLESS_MAXIMUM_MESSAGE_BYTES; + +#[cfg(target_os = "linux")] +use std::os::unix::net::UnixStream; +#[cfg(target_os = "linux")] +use std::process::Command; + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +struct TestRuntime { + root: PathBuf, + runtime: UnixRuntime, +} + +impl TestRuntime { + fn new() -> Self { + let suffix = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let root = PathBuf::from(format!( + "/tmp/headless-rust-transport-test-{}-{suffix}", + std::process::id() + )); + let runtime = UnixRuntime::new(root.clone(), root.join("host.sock")); + Self { root, runtime } + } +} + +impl Drop for TestRuntime { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +struct ChunkedReader { + data: Cursor>, + chunk_size: usize, +} + +impl Read for ChunkedReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let maximum = buffer.len().min(self.chunk_size); + self.data.read(&mut buffer[..maximum]) + } +} + +struct ChunkedWriter { + data: Vec, + chunk_size: usize, +} + +impl Write for ChunkedWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + let count = buffer.len().min(self.chunk_size); + self.data.extend_from_slice(&buffer[..count]); + Ok(count) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct WouldBlockReader; + +impl Read for WouldBlockReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::from(io::ErrorKind::WouldBlock)) + } +} + +struct InterruptedReader { + interrupted: bool, +} + +impl Read for InterruptedReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if !self.interrupted { + self.interrupted = true; + return Err(io::Error::from(io::ErrorKind::Interrupted)); + } + Cursor::new(b"response\n").read(buffer) + } +} + +struct ZeroWriter; + +impl Write for ZeroWriter { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + Ok(0) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct ScriptedConnection { + response: Cursor>, + request: Vec, +} + +impl Read for ScriptedConnection { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + self.response.read(buffer) + } +} + +impl Write for ScriptedConnection { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.request.extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn ping_request(id: &str) -> CommandRequest { + request_with_id(id, CommandName::Ping, None, BTreeMap::new()) +} + +fn create_directory(path: &Path, mode: u32) { + let mut builder = DirBuilder::new(); + builder.mode(mode); + builder.create(path).unwrap(); + fs::set_permissions(path, Permissions::from_mode(mode)).unwrap(); +} + +#[test] +fn framing_handles_partial_io_and_exact_limit() { + let mut exact = vec![b'x'; HEADLESS_MAXIMUM_MESSAGE_BYTES - 1]; + exact.push(b'\n'); + let mut reader = ChunkedReader { + data: Cursor::new(exact.clone()), + chunk_size: 37, + }; + assert_eq!(read_frame(&mut reader).unwrap(), exact); + + let mut writer = ChunkedWriter { + data: Vec::new(), + chunk_size: 19, + }; + write_frame(&mut writer, b"request\n").unwrap(); + assert_eq!(writer.data, b"request\n"); +} + +#[test] +fn framing_rejects_oversized_and_closed_inputs() { + let mut oversized = vec![b'x'; HEADLESS_MAXIMUM_MESSAGE_BYTES]; + oversized.push(b'\n'); + assert!(matches!( + read_frame(&mut Cursor::new(oversized)), + Err(TransportError::MessageTooLarge) + )); + assert!(matches!( + write_frame( + &mut Cursor::new(Vec::new()), + &vec![b'x'; HEADLESS_MAXIMUM_MESSAGE_BYTES + 1] + ), + Err(TransportError::MessageTooLarge) + )); + assert!(matches!( + read_frame(&mut Cursor::new(Vec::::new())), + Err(TransportError::ConnectionClosed) + )); + assert!(matches!( + read_frame(&mut WouldBlockReader), + Err(TransportError::TimedOut) + )); + assert_eq!( + read_frame(&mut InterruptedReader { interrupted: false }).unwrap(), + b"response\n" + ); + assert!(matches!( + write_frame(&mut ZeroWriter, b"request\n"), + Err(TransportError::ConnectionClosed) + )); +} + +#[test] +fn exchange_requires_a_correlated_response() { + let request = ping_request("request-1"); + let mismatched = codec::encode_line(&CommandResponse::success("request-2", None)).unwrap(); + let mut connection = ScriptedConnection { + response: Cursor::new(mismatched), + request: Vec::new(), + }; + assert!(matches!( + exchange(&mut connection, &request), + Err(TransportError::MismatchedResponse) + )); + + let sentinel = codec::encode_line(&CommandResponse::failure( + UNKNOWN_REQUEST_IDENTIFIER, + "INVALID_REQUEST", + "unreadable", + None, + )) + .unwrap(); + let mut connection = ScriptedConnection { + response: Cursor::new(sentinel), + request: Vec::new(), + }; + let response = exchange(&mut connection, &request).unwrap(); + assert_eq!(response.id, UNKNOWN_REQUEST_IDENTIFIER); + assert!(!response.ok); +} + +#[test] +fn runtime_and_socket_permissions_are_private() { + let test = TestRuntime::new(); + let listener = UnixControlListener::bind(&test.runtime).unwrap(); + let directory = fs::symlink_metadata(test.runtime.directory()).unwrap(); + let socket = fs::symlink_metadata(listener.socket_path()).unwrap(); + assert_eq!(directory.mode() & 0o777, 0o700); + assert_eq!(socket.mode() & 0o777, 0o600); +} + +#[test] +fn runtime_rejects_permissive_or_symlinked_directories() { + let permissive = TestRuntime::new(); + create_directory(permissive.runtime.directory(), 0o755); + assert!(matches!( + UnixControlListener::bind(&permissive.runtime), + Err(TransportError::InvalidRuntimeDirectory) + )); + + let linked = TestRuntime::new(); + let real = linked.root.with_extension("real"); + create_directory(&real, 0o700); + symlink(&real, linked.runtime.directory()).unwrap(); + assert!(matches!( + UnixControlListener::bind(&linked.runtime), + Err(TransportError::InvalidRuntimeDirectory) + )); + fs::remove_file(linked.runtime.directory()).unwrap(); + fs::remove_dir(&real).unwrap(); +} + +#[test] +fn endpoint_must_be_a_direct_child_of_runtime_directory() { + let test = TestRuntime::new(); + let outside = UnixRuntime::new( + test.runtime.directory().to_path_buf(), + test.root.with_extension("outside.sock"), + ); + assert!(matches!( + UnixControlListener::bind(&outside), + Err(TransportError::EndpointOutsideRuntimeDirectory) + )); +} + +#[test] +fn stale_socket_is_removed_but_live_socket_is_preserved() { + let test = TestRuntime::new(); + test.runtime.prepare_private_directory().unwrap(); + let stale = UnixListener::bind(test.runtime.socket()).unwrap(); + drop(stale); + assert!(test.runtime.socket().exists()); + + let listener = UnixControlListener::bind(&test.runtime).unwrap(); + assert!(matches!( + UnixControlListener::bind(&test.runtime), + Err(TransportError::AlreadyRunning) + )); + assert!(listener.socket_path().exists()); +} + +#[test] +fn listener_does_not_unlink_a_replaced_path() { + let test = TestRuntime::new(); + let listener = UnixControlListener::bind(&test.runtime).unwrap(); + fs::remove_file(test.runtime.socket()).unwrap(); + File::create(test.runtime.socket()).unwrap(); + drop(listener); + assert!(test.runtime.socket().is_file()); +} + +#[test] +fn unix_client_and_listener_round_trip() { + let test = TestRuntime::new(); + let listener = UnixControlListener::bind(&test.runtime).unwrap(); + let server = thread::spawn(move || { + let mut connection = listener.accept().unwrap(); + let frame = read_frame(&mut connection).unwrap(); + let request: CommandRequest = codec::decode_line(&frame).unwrap(); + let response = CommandResponse::success(&request.id, None); + write_frame(&mut connection, &codec::encode_line(&response).unwrap()).unwrap(); + }); + + let client = + UnixControlClient::new(test.runtime.socket().to_path_buf(), Duration::from_secs(2)); + let response = client.send(&ping_request("round-trip")).unwrap(); + assert!(response.ok); + assert_eq!(response.id, "round-trip"); + server.join().unwrap(); +} + +#[cfg(target_os = "linux")] +#[test] +fn different_peer_user_is_rejected_when_privileged() { + const CHILD_FLAG: &str = "HEADLESS_RUST_PEER_CLIENT"; + const SOCKET_PATH: &str = "HEADLESS_RUST_PEER_SOCKET"; + + if std::env::var_os(CHILD_FLAG).is_some() { + let path = std::env::var_os(SOCKET_PATH).unwrap(); + let mut stream = UnixStream::connect(PathBuf::from(path)).unwrap(); + let mut byte = [0_u8; 1]; + assert_eq!(stream.read(&mut byte).unwrap(), 0); + return; + } + + if unsafe { geteuid() } != 0 || !Path::new("/usr/bin/setpriv").is_file() { + return; + } + + let test = TestRuntime::new(); + let listener = UnixControlListener::bind(&test.runtime).unwrap(); + fs::set_permissions(test.runtime.directory(), Permissions::from_mode(0o777)).unwrap(); + fs::set_permissions(test.runtime.socket(), Permissions::from_mode(0o666)).unwrap(); + + let mut child = Command::new("/usr/bin/setpriv") + .args(["--reuid=65534", "--regid=65534", "--clear-groups"]) + .arg(std::env::current_exe().unwrap()) + .args([ + "--exact", + "different_peer_user_is_rejected_when_privileged", + "--nocapture", + ]) + .env(CHILD_FLAG, "1") + .env(SOCKET_PATH, test.runtime.socket()) + .spawn() + .unwrap(); + + assert!(matches!(listener.accept(), Err(TransportError::PeerDenied))); + assert!(child.wait().unwrap().success()); +} + +#[test] +fn invalid_request_fails_before_connecting() { + let test = TestRuntime::new(); + let client = + UnixControlClient::new(test.runtime.socket().to_path_buf(), Duration::from_secs(1)); + let invalid = request_with_id("", CommandName::Ping, None, BTreeMap::new()); + assert!(matches!( + client.send(&invalid), + Err(TransportError::Protocol(_)) + )); + assert!(matches!( + client.send(&ping_request("missing-host")), + Err(TransportError::ConnectionFailed) + )); +} + +#[cfg(target_os = "linux")] +extern "C" { + fn geteuid() -> u32; +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8ad464c..45b79dc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -283,6 +283,9 @@ Best-effort goal, explicitly **not required for "done"**. Prerequisite: Phase 2's engine/transport split. Shape of the work (detailed in [architecture-decisions §6](roadmap/architecture-decisions.md)): +- The Rust control-transport seam and secure Unix backend are implemented in + [#140](https://github.com/LockInTime/headless/issues/140), preserving the + shipping local-security contract while leaving the Windows backend explicit. - Named-pipe transport with SID-based peer checks replacing the Unix socket. - Win32 process/pipe layer for Chromium's `--remote-debugging-pipe` (HANDLE inheritance instead of fd 3/4). diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index e1d2ede..ad0f6e9 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -465,6 +465,13 @@ disturbing the shipping Swift product: a Swift-host decision; for Rust these two are the ecosystem baseline and are pinned. +**Progress, 2026-08-27:** #140 adds the platform-neutral connection/listener +seam, bounded newline framing, response correlation, and the secure Unix +backend. The Unix implementation preserves the private `0700` runtime, +`0600` socket, effective-UID peer authorization, stale-socket safety, and +live-endpoint protection. This does not implement or claim Windows transport; +the named-pipe, ACL, and SID-authentication backend remains a separate step. + The Swift product remains the reference implementation until the Rust core passes an equivalent conformance suite; only then can it start replacing hosts. Nothing in this decision changes the hard rules: no arbitrary-JS verb, diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 73598b2..dbcb7d2 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -482,6 +482,21 @@ get an architecture-decision entry: --- +## §W — Windows stretch work ([#56](https://github.com/LockInTime/headless/issues/56)) + +- **W1. Rust control-transport seam and secure Unix backend.** + ([#140](https://github.com/LockInTime/headless/issues/140)) ~~Port bounded + framing, correlated request/response exchange, private runtime-directory + validation, stale/live Unix-socket handling, and peer-UID authorization. + Keep Windows named pipes, ACLs, and SID checks as a separate backend so an + incomplete implementation cannot be mistaken for a security boundary.~~ + **Done:** the platform-independent framing and exchange code compiles across + Linux, macOS, and Windows; the Unix backend and adversarial transport suite + preserve the shipping Swift security contract without claiming named-pipe + support. + +--- + ## Priority key Phase 1 = §A + §C1–C3 + §D1–D3. Phase 2 = §B + §D4. Phase 3 = §E.