diff --git a/src/app.rs b/src/app.rs index fa0d9b3..6b63972 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, diff --git a/src/daemon.rs b/src/daemon.rs index 0601df3..dbf47da 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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 { - 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 { 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, } } @@ -498,22 +497,22 @@ fn is_v2_hello(kind: u8, payload: &[u8]) -> Option { fn handshake(stream: &mut UnixStream) -> Result { 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"), diff --git a/src/frame.rs b/src/frame.rs index 79763b3..88b83f9 100644 --- a/src/frame.rs +++ b/src/frame.rs @@ -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)?; @@ -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 = 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"); + } } diff --git a/src/protocol.rs b/src/protocol.rs index 1d5964a..16a732e 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -11,7 +11,7 @@ use base64::engine::general_purpose::STANDARD as B64; use crate::frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}; /// Wire-protocol version; the handshake rejects mismatched peers. -pub const PROTOCOL_VERSION: u32 = 3; +pub const PROTOCOL_VERSION: u32 = 4; /// Environment and working directory supplied by the launching client. #[derive(Debug, Clone, PartialEq)] @@ -177,11 +177,6 @@ pub struct ScreenView { // raw tail after a small jzon header rather than bloating into a JSON number // array. A socket peer is just `decode_*(read_frame(...))`. -/// Encode paths as lossy UTF-8 strings for the protocol. -fn ps(p: &Path) -> String { - p.to_string_lossy().into_owned() -} - /// Encode an `OsStr` as lossless base64 for a JSON string. fn os_b64(s: &OsStr) -> String { B64.encode(s.as_bytes()) @@ -192,6 +187,21 @@ fn os_from_b64(v: &jzon::JsonValue) -> Option { Some(OsString::from_vec(B64.decode(v.as_str()?).ok()?)) } +/// Encode a path's Unix bytes as base64 without requiring UTF-8. +fn path_b64(p: &Path) -> String { + os_b64(p.as_os_str()) +} + +/// Decode a strictly valid base64 JSON string as a `PathBuf`. +fn path_from_b64(v: &jzon::JsonValue) -> Option { + Some(PathBuf::from(os_from_b64(v)?)) +} + +/// Decode a JSON number as a `u16`, rejecting out-of-range values. +fn u16_from(v: &jzon::JsonValue) -> Option { + u16::try_from(v.as_u64()?).ok() +} + fn lifecycle_str(l: Lifecycle) -> &'static str { match l { Lifecycle::Active => "active", @@ -215,7 +225,7 @@ fn lifecycle_from(s: &str) -> Option { pub fn encode_hello(ctx: &LaunchContext) -> (u8, Vec) { let mut o = jzon::JsonValue::new_object(); let _ = o.insert("v", PROTOCOL_VERSION); - let _ = o.insert("cwd", ps(&ctx.cwd)); + let _ = o.insert("cwd", path_b64(&ctx.cwd)); let mut pairs = jzon::JsonValue::new_array(); for (k, v) in &ctx.env { let mut pair = jzon::JsonValue::new_array(); @@ -242,7 +252,7 @@ pub fn decode_hello(kind: u8, payload: &[u8]) -> Option<(u32, LaunchContext)> { v["v"].as_u32()?, LaunchContext { env, - cwd: PathBuf::from(v["cwd"].as_str()?), + cwd: path_from_b64(&v["cwd"])?, }, )) } @@ -255,7 +265,7 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { Command::Spawn { command, cwd } => { let _ = o.insert("t", "spawn"); let _ = o.insert("command", command.as_str()); - let _ = o.insert("cwd", ps(cwd)); + let _ = o.insert("cwd", path_b64(cwd)); } Command::Kill { id } => { let _ = o.insert("t", "kill"); @@ -290,23 +300,17 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { } } } + // Encode both byte-carrying commands as base64. The paste-size bound in + // `app` accounts for base64 expansion and the frame limit. Command::Input { id, bytes } => { let _ = o.insert("t", "input"); let _ = o.insert("id", *id); - let mut arr = jzon::JsonValue::new_array(); - for b in bytes { - let _ = arr.push(*b as u64); - } - let _ = o.insert("bytes", arr); + let _ = o.insert("bytes", B64.encode(bytes)); } Command::Paste { id, bytes } => { let _ = o.insert("t", "paste"); let _ = o.insert("id", *id); - let mut arr = jzon::JsonValue::new_array(); - for b in bytes { - let _ = arr.push(*b as u64); - } - let _ = o.insert("bytes", arr); + let _ = o.insert("bytes", B64.encode(bytes)); } Command::Mouse { id, kind, col, row } => { let _ = o.insert("t", "mouse"); @@ -355,8 +359,9 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { } /// Parse a command from a received frame. `None` on a wrong kind, non-UTF-8/ -/// non-JSON payload, unknown discriminant, or a missing/mistyped field. The -/// daemon drops a malformed command rather than trusting it. +/// non-JSON payload, unknown discriminant, or a missing/mistyped field — +/// including out-of-range numerics and invalid base64. The daemon drops a +/// malformed command rather than trusting it. pub fn decode_command(kind: u8, payload: &[u8]) -> Option { if kind != KIND_CONTROL { return None; @@ -365,7 +370,7 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { let cmd = match v["t"].as_str()? { "spawn" => Command::Spawn { command: v["command"].as_str()?.to_string(), - cwd: PathBuf::from(v["cwd"].as_str()?), + cwd: path_from_b64(&v["cwd"])?, }, "kill" => Command::Kill { id: v["id"].as_u64()?, @@ -381,8 +386,8 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { on: v["on"].as_bool()?, }, "resize" => Command::Resize { - rows: v["rows"].as_u64()? as u16, - cols: v["cols"].as_u64()? as u16, + rows: u16_from(&v["rows"])?, + cols: u16_from(&v["cols"])?, }, "watch" => Command::Watch { id: if v["id"].is_null() { @@ -393,17 +398,11 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { }, "input" => Command::Input { id: v["id"].as_u64()?, - bytes: v["bytes"] - .members() - .filter_map(|m| m.as_u64().map(|n| n as u8)) - .collect(), + bytes: B64.decode(v["bytes"].as_str()?).ok()?, }, "paste" => Command::Paste { id: v["id"].as_u64()?, - bytes: v["bytes"] - .members() - .filter_map(|m| m.as_u64().map(|n| n as u8)) - .collect(), + bytes: B64.decode(v["bytes"].as_str()?).ok()?, }, "mouse" => { let btn = || -> Option { @@ -424,15 +423,15 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { "r" => MouseKind::Release(btn()?), _ => return None, }, - col: v["col"].as_u64()? as u16, - row: v["row"].as_u64()? as u16, + col: u16_from(&v["col"])?, + row: u16_from(&v["row"])?, } } "sb" => Command::Scrollback { id: v["id"].as_u64()?, action: match v["a"].as_str()? { - "u" => ScrollAction::Up(v["n"].as_u64()? as u16), - "d" => ScrollAction::Down(v["n"].as_u64()? as u16), + "u" => ScrollAction::Up(u16_from(&v["n"])?), + "d" => ScrollAction::Down(u16_from(&v["n"])?), "t" => ScrollAction::Top, "l" => ScrollAction::Live, _ => return None, @@ -466,7 +465,7 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { let mut o = jzon::JsonValue::new_object(); let _ = o.insert("id", tv.id); let _ = o.insert("command", tv.command.as_str()); - let _ = o.insert("cwd", ps(&tv.cwd)); + let _ = o.insert("cwd", path_b64(&tv.cwd)); let _ = o.insert("tagged", tv.tagged); let _ = o.insert("life", lifecycle_str(tv.lifecycle)); let _ = o.insert("preview", tv.preview.as_str()); @@ -525,7 +524,7 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { views.push(TaskView { id: tv["id"].as_u64()?, command: tv["command"].as_str()?.to_string(), - cwd: PathBuf::from(tv["cwd"].as_str()?), + cwd: path_from_b64(&tv["cwd"])?, tagged: tv["tagged"].as_bool()?, lifecycle: lifecycle_from(tv["life"].as_str()?)?, preview: tv["preview"].as_str()?.to_string(), @@ -543,14 +542,13 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { let header_bytes = payload.get(4..4 + hlen)?; let formatted = payload.get(4 + hlen..)?.to_vec(); let h = jzon::parse(std::str::from_utf8(header_bytes).ok()?).ok()?; - let cursor = ( - h["cursor"][0].as_u64()? as u16, - h["cursor"][1].as_u64()? as u16, - ); - let lines = h["lines"] - .members() - .filter_map(|m| m.as_str().map(str::to_string)) - .collect(); + let cursor = (u16_from(&h["cursor"][0])?, u16_from(&h["cursor"][1])?); + // Preserve the one-to-one mapping between encoded and decoded rows. + // A non-string row invalidates the event. + let mut lines = Vec::with_capacity(h["lines"].len()); + for l in h["lines"].members() { + lines.push(l.as_str()?.to_string()); + } Some(Event::Screen(ScreenView { id: h["id"].as_u64()?, lines, @@ -559,7 +557,7 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { hide_cursor: h["hide"].as_bool()?, wants_mouse: h["mouse"].as_bool()?, alt_screen: h["alt"].as_bool()?, - scrollback: h["sb"].as_u64()? as usize, + scrollback: usize::try_from(h["sb"].as_u64()?).ok()?, })) } _ => None, @@ -580,6 +578,11 @@ mod tests { command: "echo hi".into(), cwd: PathBuf::from("/tmp"), }, + Command::Spawn { + // Exercise byte-preserving serialization of a non-UTF-8 path. + command: "ls".into(), + cwd: PathBuf::from(OsString::from_vec(b"/tmp/\xff\xfe dir".to_vec())), + }, Command::Kill { id: 7 }, Command::Remove { id: 3 }, Command::Restart { id: 4 }, @@ -662,7 +665,8 @@ mod tests { assert_eq!(decode_event(k, &p), Some(ack)); } - /// Handshake environment entries round-trip byte-for-byte. + /// Handshake environment entries and the cwd round-trip byte-for-byte, + /// including non-UTF-8 bytes in both. #[test] fn hello_round_trips() { let ctx = LaunchContext { @@ -673,7 +677,7 @@ mod tests { OsString::from_vec(b"v\xff".to_vec()), ), ], - cwd: PathBuf::from("/home/x"), + cwd: PathBuf::from(OsString::from_vec(b"/home/x\xff\xfe".to_vec())), }; let (k, p) = encode_hello(&ctx); assert_eq!(k, KIND_HELLO); @@ -705,10 +709,11 @@ mod tests { r#"[["P@TH","L2Jpbg=="]]"#, // invalid base64 character r#"[["QUFBQUE","L2Jpbg=="]]"#, // truncated: missing padding r#"[["UEFUSA==","AAAA="]]"#, // bad padding length - r#"[[[80],[65]]]"#, // v2 number arrays are not v3 + r#"[[[80],[65]]]"#, // env pairs must contain base64 strings r#"["PATH=/bin"]"#, // flat string pair ] { - let json = format!(r#"{{"v":3,"cwd":"/","env":{env}}}"#); + // Keep the cwd valid so each case isolates env validation. + let json = format!(r#"{{"v":4,"cwd":"Lw==","env":{env}}}"#); assert_eq!( decode_hello(KIND_HELLO, json.as_bytes()), None, @@ -717,17 +722,111 @@ mod tests { } } + /// A hello with a non-base64 cwd is rejected. + #[test] + fn hello_with_malformed_cwd_is_rejected() { + let json = r#"{"v":3,"cwd":"/home/user","env":[]}"#; + assert_eq!(decode_hello(KIND_HELLO, json.as_bytes()), None); + } + + /// Out-of-range numeric fields reject the whole command. + #[test] + fn out_of_range_numerics_are_rejected() { + for json in [ + r#"{"t":"resize","rows":65536,"cols":100}"#, + r#"{"t":"resize","rows":30,"cols":65536}"#, + r#"{"t":"mouse","id":1,"k":"wu","col":65536,"row":0}"#, + r#"{"t":"mouse","id":1,"k":"wu","col":0,"row":65536}"#, + r#"{"t":"sb","id":1,"a":"u","n":65536}"#, + r#"{"t":"sb","id":1,"a":"d","n":-1}"#, + ] { + assert_eq!( + decode_command(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } + } + + /// Invalid base64 and non-string byte or path fields reject the command. + #[test] + fn invalid_base64_is_rejected() { + for json in [ + r#"{"t":"input","id":1,"bytes":"!!!"}"#, + r#"{"t":"input","id":1,"bytes":[0,27]}"#, // bytes must be a base64 string + r#"{"t":"paste","id":1,"bytes":"AAAA="}"#, // bad padding length + r#"{"t":"spawn","command":"ls","cwd":"/tmp/x"}"#, // plain path + ] { + assert_eq!( + decode_command(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } + } + + /// Build a `KIND_SCREEN` payload (`[u32 header_len][header]`, empty tail) + /// from a raw header string, for malformed-header tests. + fn screen_payload(header: &str) -> Vec { + let mut p = Vec::with_capacity(4 + header.len()); + p.extend_from_slice(&(header.len() as u32).to_be_bytes()); + p.extend_from_slice(header.as_bytes()); + p + } + + /// A mistyped member in `lines` or `tasks` rejects the whole event, keeping + /// decoded rows aligned with their encoded positions. + #[test] + fn mistyped_event_members_are_rejected() { + for header in [ + // Numeric member in `lines`. + r#"{"id":1,"cursor":[0,0],"hide":false,"mouse":false,"alt":false,"sb":0,"lines":["ok",5]}"#, + // Out-of-range cursor cell. + r#"{"id":1,"cursor":[65536,0],"hide":false,"mouse":false,"alt":false,"sb":0,"lines":[]}"#, + ] { + assert_eq!( + decode_event(KIND_SCREEN, &screen_payload(header)), + None, + "should reject header {header}" + ); + } + for json in [ + r#"{"t":"tasks","tasks":[{"id":"nope"}]}"#, + // The cwd must be a base64 string. + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"/x","tagged":true,"life":"ok","preview":"","started_ms":0}]}"#, + r#"{"t":"tasks","tasks":["flat"]}"#, + ] { + assert_eq!( + decode_event(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } + } + #[test] fn tasks_and_status_round_trip() { - let tasks = Event::Tasks(vec![TaskView { - id: 1, - command: "vim".into(), - cwd: PathBuf::from("/home/x"), - tagged: true, - lifecycle: Lifecycle::Idle, - preview: "~ line".into(), - started_ago: Duration::from_millis(4200), - }]); + let tasks = Event::Tasks(vec![ + TaskView { + id: 1, + command: "vim".into(), + cwd: PathBuf::from("/home/x"), + tagged: true, + lifecycle: Lifecycle::Idle, + preview: "~ line".into(), + started_ago: Duration::from_millis(4200), + }, + TaskView { + id: 2, + command: "make".into(), + // Exercise byte-preserving task-path serialization. + cwd: PathBuf::from(OsString::from_vec(b"/srv/\xff\xfe".to_vec())), + tagged: false, + lifecycle: Lifecycle::Active, + preview: String::new(), + started_ago: Duration::from_millis(10), + }, + ]); let (k, p) = encode_event(&tasks); assert_eq!(k, KIND_CONTROL); assert_eq!(decode_event(k, &p), Some(tasks)); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 6b893df..3c89269 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -15,7 +15,7 @@ use std::time::{Duration, Instant}; /// The protocol version this test suite speaks; must track /// `protocol::PROTOCOL_VERSION` (drift fails the handshake, loudly). -pub const PROTOCOL_VERSION: u32 = 3; +pub const PROTOCOL_VERSION: u32 = 4; /// One frame of the given kind: `[u32 len][kind][payload]`. pub fn frame(kind: u8, payload: &[u8]) -> Vec { @@ -69,13 +69,15 @@ pub fn read_frame(stream: &mut UnixStream) -> std::io::Result<(u8, Vec)> { } /// A `KIND_HELLO` frame carrying the client environment and working directory. +/// The cwd and environment strings are base64-encoded Unix bytes. pub fn hello_frame(version: u32, env: &[(&[u8], &[u8])], cwd: &str) -> Vec { let pairs: Vec = env .iter() .map(|(k, v)| format!(r#"["{}","{}"]"#, b64(k), b64(v))) .collect(); let json = format!( - r#"{{"v":{version},"cwd":"{cwd}","env":[{}]}}"#, + r#"{{"v":{version},"cwd":"{}","env":[{}]}}"#, + b64(cwd.as_bytes()), pairs.join(",") ); frame(3, json.as_bytes()) diff --git a/tests/daemon_env.rs b/tests/daemon_env.rs index bba0c6f..233406a 100644 --- a/tests/daemon_env.rs +++ b/tests/daemon_env.rs @@ -11,7 +11,7 @@ use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; use common::{ - PROTOCOL_VERSION, control_frame, hello_frame, read_frame, start_daemon_raw, wait_until, + PROTOCOL_VERSION, b64, control_frame, hello_frame, read_frame, start_daemon_raw, wait_until, }; #[test] @@ -46,6 +46,7 @@ fn spawn_runs_under_the_hello_env() { let spawn = format!( r#"{{"t":"spawn","command":"printf '%s:%s' \"$FLEETCOM_MARKER\" \"${{FLEETCOM_DAEMON_ONLY:-absent}}\" > {out}","cwd":"{cwd}"}}"#, out = out.display(), + cwd = b64(cwd.as_bytes()), ); stream.write_all(&control_frame(&spawn)).unwrap(); diff --git a/tests/daemon_handshake.rs b/tests/daemon_handshake.rs index c4a790e..620301a 100644 --- a/tests/daemon_handshake.rs +++ b/tests/daemon_handshake.rs @@ -5,7 +5,9 @@ mod common; use std::io::Write; use std::time::Duration; -use common::{control_frame, hello_frame, read_frame, start_daemon, start_daemon_raw, wait_until}; +use common::{ + control_frame, frame, hello_frame, read_frame, start_daemon, start_daemon_raw, wait_until, +}; /// Read the refusal `Status`, assert `needle` appears, then require EOF: the /// daemon must close, not serve. @@ -39,6 +41,19 @@ fn version_mismatch_is_refused_with_both_versions_named() { let _ = std::fs::remove_dir_all(&dir); } +/// A hello that claims version 3 but fails strict field decoding is still +/// reported as a version mismatch. +#[test] +fn v3_hello_is_refused_as_a_version_mismatch() { + let (dir, daemon, mut stream) = start_daemon_raw("v3hello", |_| {}); + // The underscore in the runtime path makes this cwd invalid standard base64. + let v3 = format!(r#"{{"v":3,"cwd":"{}","env":[]}}"#, dir.display()); + stream.write_all(&frame(3, v3.as_bytes())).unwrap(); + expect_refusal(&mut stream, "v3"); + drop(daemon); + let _ = std::fs::remove_dir_all(&dir); +} + /// A v2 control-frame hello is reported as a version mismatch. #[test] fn v2_hello_is_refused_as_a_version_mismatch() { diff --git a/tests/daemon_kill.rs b/tests/daemon_kill.rs index c6d133e..521eff5 100644 --- a/tests/daemon_kill.rs +++ b/tests/daemon_kill.rs @@ -10,7 +10,7 @@ use std::time::Duration; use nix::sys::signal::kill; use nix::unistd::Pid; -use common::{control_frame, start_daemon, wait_until}; +use common::{b64, control_frame, start_daemon, wait_until}; #[test] fn kill_works_while_a_client_is_attached() { @@ -22,7 +22,7 @@ fn kill_works_while_a_client_is_attached() { let spawn = format!( r#"{{"t":"spawn","command":"echo $$ > {pf} && sleep 300","cwd":"{cwd}"}}"#, pf = pidfile.display(), - cwd = dir.display() + cwd = b64(dir.display().to_string().as_bytes()) ); stream.write_all(&control_frame(&spawn)).unwrap(); assert!( diff --git a/tests/daemon_lifetime.rs b/tests/daemon_lifetime.rs index 18c939f..ae7f928 100644 --- a/tests/daemon_lifetime.rs +++ b/tests/daemon_lifetime.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; use nix::sys::signal::{Signal, kill, killpg}; use nix::unistd::Pid; -use common::{control_frame, start_daemon, wait_until}; +use common::{b64, control_frame, start_daemon, wait_until}; /// Spawn `command` (which must write its own `$$` to `pidfile`) and return the /// job's leader pid (== pgid: portable-pty `setsid`s it). @@ -23,7 +23,7 @@ fn spawn_job( ) -> Pid { let spawn = format!( r#"{{"t":"spawn","command":"{command}","cwd":"{cwd}"}}"#, - cwd = cwd.display(), + cwd = b64(cwd.display().to_string().as_bytes()), ); stream.write_all(&control_frame(&spawn)).unwrap(); assert!( diff --git a/tests/daemon_signal.rs b/tests/daemon_signal.rs index 1d8ed0c..d77b34e 100644 --- a/tests/daemon_signal.rs +++ b/tests/daemon_signal.rs @@ -10,7 +10,7 @@ use std::time::Duration; use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; -use common::{control_frame, start_daemon, wait_until}; +use common::{b64, control_frame, start_daemon, wait_until}; #[test] fn sigterm_kills_daemon_and_its_jobs() { @@ -23,7 +23,7 @@ fn sigterm_kills_daemon_and_its_jobs() { let spawn = format!( r#"{{"t":"spawn","command":"echo $$ > {pf} && sleep 300","cwd":"{cwd}"}}"#, pf = pidfile.display(), - cwd = dir.display() + cwd = b64(dir.display().to_string().as_bytes()) ); stream.write_all(&control_frame(&spawn)).unwrap();