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
7 changes: 7 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ use crate::ui;
/// Maximum attached paste size, leaving headroom below the frame limit.
const MAX_PASTE: usize = 8 * 1024 * 1024;

// A maximum-size paste expands to this base64 bound in `encode_command`.
// Reserve 64 KiB for the command envelope and keep the result within one frame.
const _: () = assert!(
MAX_PASTE.div_ceil(3) * 4 + 64 * 1024 <= crate::frame::MAX_FRAME as usize,
"MAX_PASTE must base64-encode to under frame::MAX_FRAME"
);

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Dashboard,
Expand Down
41 changes: 20 additions & 21 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,16 +480,15 @@ enum ServeOutcome {
Shutdown,
}

/// Return the version from a v2 control-frame hello.
fn is_v2_hello(kind: u8, payload: &[u8]) -> Option<u32> {
if kind != crate::frame::KIND_CONTROL {
return None;
}
/// Extract a claimed protocol version for mismatch reporting.
/// Accepts hello-kind frames and control frames with a `hello` discriminant,
/// even when the remaining fields do not satisfy [`decode_hello`].
fn hello_version(kind: u8, payload: &[u8]) -> Option<u32> {
let v = jzon::parse(std::str::from_utf8(payload).ok()?).ok()?;
if v["t"].as_str()? == "hello" {
v["v"].as_u32()
} else {
None
match kind {
crate::frame::KIND_HELLO => v["v"].as_u32(),
crate::frame::KIND_CONTROL if v["t"].as_str() == Some("hello") => v["v"].as_u32(),
_ => None,
}
}

Expand All @@ -498,22 +497,22 @@ fn is_v2_hello(kind: u8, payload: &[u8]) -> Option<u32> {
fn handshake(stream: &mut UnixStream) -> Result<LaunchContext, String> {
let (kind, payload) = read_frame_bounded(stream, HANDSHAKE_TIMEOUT)
.map_err(|e| format!("no valid hello received: {e}"))?;
match decode_hello(kind, &payload) {
Some((PROTOCOL_VERSION, ctx)) => Ok(ctx),
Some((version, _)) => Err(format!(
let mismatch = |version: u32| {
format!(
"protocol mismatch: daemon {} speaks v{PROTOCOL_VERSION}, client speaks \
v{version}; run 'fleetcom --kill' and retry",
env!("CARGO_PKG_VERSION"),
)),
// Report a v2 hello as a version mismatch.
None => match is_v2_hello(kind, &payload) {
Some(version) => Err(format!(
"protocol mismatch: daemon {} speaks v{PROTOCOL_VERSION}, client speaks \
v{version}; run 'fleetcom --kill' and retry",
env!("CARGO_PKG_VERSION"),
)),
)
};
match decode_hello(kind, &payload) {
Some((PROTOCOL_VERSION, ctx)) => Ok(ctx),
Some((version, _)) => Err(mismatch(version)),
// When strict decoding fails, a different claimed version is still a
// protocol mismatch. A same-version payload is malformed instead.
None => match hello_version(kind, &payload) {
Some(version) if version != PROTOCOL_VERSION => Err(mismatch(version)),
// Refuse anything else sent before the required handshake.
None => Err(format!(
_ => Err(format!(
"daemon {} requires a hello handshake (older client?); upgrade the \
client or run 'fleetcom --kill' and retry",
env!("CARGO_PKG_VERSION"),
Expand Down
23 changes: 18 additions & 5 deletions src/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,21 @@ pub const KIND_SCREEN: u8 = 2;
/// context. Handshakes are not command frames.
pub const KIND_HELLO: u8 = 3;

/// Reject an absurd length prefix (corrupt or hostile peer) before allocating.
/// 64 MiB is far above any real frame. A full 8K screen's formatted bytes are
/// a few hundred KiB at most.
const MAX_FRAME: u32 = 64 * 1024 * 1024;
/// Maximum frame payload size accepted from readers and emitted by writers.
/// This bounds allocations from untrusted length prefixes and lets producers
/// verify that their maximum encoded payload fits.
pub const MAX_FRAME: u32 = 64 * 1024 * 1024;

/// Write one frame and flush. Flushing per frame keeps latency low: the peer sees
/// each command/event immediately. A firehose can't drown the socket because the
/// core loop already coalesces screen emission to one frame per `FRAME_MIN` (see
/// `core::run_loop`). The flush here is per *emitted* frame, not per output byte.
pub fn write_frame(w: &mut impl Write, kind: u8, payload: &[u8]) -> io::Result<()> {
// Reject an oversized payload before writing any part of the frame.
let len = u32::try_from(payload.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "frame too large"))?;
.ok()
.filter(|len| *len <= MAX_FRAME)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "frame too large"))?;
w.write_all(&len.to_be_bytes())?;
w.write_all(&[kind])?;
w.write_all(payload)?;
Expand Down Expand Up @@ -105,4 +108,14 @@ mod tests {
(KIND_CONTROL, b"split me".to_vec())
);
}

/// An oversized payload fails without writing a partial frame.
#[test]
fn oversized_write_fails_locally() {
let payload = vec![0u8; MAX_FRAME as usize + 1];
let mut buf: Vec<u8> = Vec::new();
let err = write_frame(&mut buf, KIND_CONTROL, &payload).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(buf.is_empty(), "no bytes may reach the stream");
}
}
Loading
Loading