Skip to content

Commit ff62819

Browse files
authored
feat(core-rs): add secure local control transport (#141)
1 parent 68b1bfa commit ff62819

8 files changed

Lines changed: 990 additions & 2 deletions

File tree

‎PLAN.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ we port only the shared core to Rust, never the whole product.
5858
- Step 2: adapter ruled out. Per architecture decision §21, the shared core
5959
is being ported to Rust instead: `apps/headless-rs` now carries the full
6060
protocol layer (39 commands, strict validation, navigation boundary,
61-
artifact rules, 1 MiB codec) with 10 tests mirroring the Swift suite's
61+
artifact rules, 1 MiB codec) plus the platform-neutral control-transport
62+
seam and secure Unix backend from #140. The Rust suite mirrors the Swift
6263
security-critical cases, and CI builds it natively on Linux, macOS, and
63-
Windows. Next increments: transport, CLI parser, Chromium CDP host.
64+
Windows. Next increments: Windows named pipes, CLI parser, Chromium CDP host.
6465
- Step 3: WSL2 install documented in README; macOS/Linux packaging already
6566
shipped in the E-series work. Once the Rust core gains a host, Windows
6667
gets a real native story (winget/MSI) instead of WSL2.

‎apps/headless-rs/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
pub mod error;
1010
pub mod json;
1111
pub mod protocol;
12+
pub mod transport;
1213
pub mod url;
1314
pub mod validate;
1415

‎apps/headless-rs/src/transport.rs‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//! Local control transport primitives.
2+
//!
3+
//! Framing and request correlation are platform-independent. Platform
4+
//! backends provide authenticated local connections without adding a network
5+
//! listener. The Unix backend is implemented first; Windows named pipes plug
6+
//! into the same traits in a later increment.
7+
8+
use std::fmt;
9+
use std::io::{self, Read, Write};
10+
11+
use crate::error::ValidationError;
12+
use crate::protocol::{codec, CommandRequest, CommandResponse, UNKNOWN_REQUEST_IDENTIFIER};
13+
use crate::HEADLESS_MAXIMUM_MESSAGE_BYTES;
14+
15+
#[cfg(unix)]
16+
pub mod unix;
17+
18+
#[derive(Debug)]
19+
pub enum TransportError {
20+
TimedOut,
21+
ConnectionFailed,
22+
ConnectionClosed,
23+
MessageTooLarge,
24+
InvalidRuntimeDirectory,
25+
EndpointOutsideRuntimeDirectory,
26+
AlreadyRunning,
27+
PeerDenied,
28+
MismatchedResponse,
29+
Protocol(ValidationError),
30+
Io {
31+
operation: &'static str,
32+
source: io::Error,
33+
},
34+
}
35+
36+
impl TransportError {
37+
fn from_io(operation: &'static str, error: io::Error) -> Self {
38+
match error.kind() {
39+
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => Self::TimedOut,
40+
io::ErrorKind::BrokenPipe
41+
| io::ErrorKind::ConnectionAborted
42+
| io::ErrorKind::ConnectionReset
43+
| io::ErrorKind::NotConnected
44+
| io::ErrorKind::UnexpectedEof
45+
| io::ErrorKind::WriteZero => Self::ConnectionClosed,
46+
_ => Self::Io {
47+
operation,
48+
source: error,
49+
},
50+
}
51+
}
52+
}
53+
54+
impl fmt::Display for TransportError {
55+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56+
match self {
57+
Self::TimedOut => write!(f, "Headless host did not respond before the deadline"),
58+
Self::ConnectionFailed => write!(f, "Headless host is not running"),
59+
Self::ConnectionClosed => write!(f, "Headless host closed the connection"),
60+
Self::MessageTooLarge => write!(f, "Headless host message exceeded the size limit"),
61+
Self::InvalidRuntimeDirectory => {
62+
write!(
63+
f,
64+
"Local runtime directory is not private to the current user"
65+
)
66+
}
67+
Self::EndpointOutsideRuntimeDirectory => {
68+
write!(
69+
f,
70+
"Local endpoint must be inside the private runtime directory"
71+
)
72+
}
73+
Self::AlreadyRunning => write!(
74+
f,
75+
"Another Headless host is already using the local endpoint"
76+
),
77+
Self::PeerDenied => write!(f, "Local transport peer is not authorized"),
78+
Self::MismatchedResponse => write!(f, "Headless host replied to a different request"),
79+
Self::Protocol(error) => write!(f, "{error}"),
80+
Self::Io { operation, source } => {
81+
write!(f, "Local transport {operation} failed: {source}")
82+
}
83+
}
84+
}
85+
}
86+
87+
impl std::error::Error for TransportError {
88+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
89+
match self {
90+
Self::Protocol(error) => Some(error),
91+
Self::Io { source, .. } => Some(source),
92+
_ => None,
93+
}
94+
}
95+
}
96+
97+
impl From<ValidationError> for TransportError {
98+
fn from(value: ValidationError) -> Self {
99+
Self::Protocol(value)
100+
}
101+
}
102+
103+
pub trait ControlConnection: Read + Write + Send {}
104+
105+
impl<T: Read + Write + Send> ControlConnection for T {}
106+
107+
pub trait ControlListener {
108+
type Connection: ControlConnection;
109+
110+
fn accept(&self) -> Result<Self::Connection, TransportError>;
111+
}
112+
113+
/// Read exactly one newline-delimited frame without allowing its buffer to
114+
/// grow beyond the wire cap. Bytes after the first newline are irrelevant to
115+
/// the one-request-per-connection protocol and are intentionally ignored.
116+
pub fn read_frame<R: Read>(reader: &mut R) -> Result<Vec<u8>, TransportError> {
117+
let mut frame = Vec::with_capacity(8_192);
118+
let mut chunk = [0_u8; 8_192];
119+
120+
loop {
121+
let count = loop {
122+
match reader.read(&mut chunk) {
123+
Ok(count) => break count,
124+
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
125+
Err(error) => return Err(TransportError::from_io("read", error)),
126+
}
127+
};
128+
if count == 0 {
129+
return Err(TransportError::ConnectionClosed);
130+
}
131+
132+
if let Some(newline) = chunk[..count].iter().position(|byte| *byte == b'\n') {
133+
let frame_end = newline + 1;
134+
if frame.len() + frame_end > HEADLESS_MAXIMUM_MESSAGE_BYTES {
135+
return Err(TransportError::MessageTooLarge);
136+
}
137+
frame.extend_from_slice(&chunk[..frame_end]);
138+
return Ok(frame);
139+
}
140+
141+
if frame.len() + count >= HEADLESS_MAXIMUM_MESSAGE_BYTES {
142+
return Err(TransportError::MessageTooLarge);
143+
}
144+
frame.extend_from_slice(&chunk[..count]);
145+
}
146+
}
147+
148+
pub fn write_frame<W: Write>(writer: &mut W, frame: &[u8]) -> Result<(), TransportError> {
149+
if frame.len() > HEADLESS_MAXIMUM_MESSAGE_BYTES {
150+
return Err(TransportError::MessageTooLarge);
151+
}
152+
writer
153+
.write_all(frame)
154+
.map_err(|error| TransportError::from_io("write", error))
155+
}
156+
157+
/// Exchange one validated request and correlated response on an authenticated
158+
/// local connection.
159+
pub fn exchange<C: ControlConnection>(
160+
connection: &mut C,
161+
request: &CommandRequest,
162+
) -> Result<CommandResponse, TransportError> {
163+
request.validate()?;
164+
exchange_validated(connection, request)
165+
}
166+
167+
pub(crate) fn exchange_validated<C: ControlConnection>(
168+
connection: &mut C,
169+
request: &CommandRequest,
170+
) -> Result<CommandResponse, TransportError> {
171+
let request_frame = codec::encode_line(request)?;
172+
write_frame(connection, &request_frame)?;
173+
174+
let response_frame = read_frame(connection)?;
175+
let response: CommandResponse = codec::decode_line(&response_frame)?;
176+
if response.id != request.id && response.id != UNKNOWN_REQUEST_IDENTIFIER {
177+
return Err(TransportError::MismatchedResponse);
178+
}
179+
Ok(response)
180+
}

0 commit comments

Comments
 (0)