mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-31 16:02:34 +00:00
46d79165f5
Bug from the prior commit: tunnel ping echoes could splice into the middle of an in-flight guacd instruction, breaking the browser's parser. The parser concatenates every Message::Text into one rolling buffer with no message-boundary semantics, so two halves of a guacd instruction sent either side of a ping echo got parsed as one corrupted instruction: [rustguac] tunnel error: Element terminator of instruction was not ";" nor ",". code=512 Reproduced reliably on sol1-remoteconsole post-upgrade, after AudioContext resumed (the larger audio-init burst made mid-instruction reads more likely). Fix: every Message::Text from rustguac to the browser now ends at a true Guacamole instruction boundary. guacd_to_ws holds an incremental carry buffer; after each TCP read it flushes only up to the last complete instruction (length-prefix-aware so embedded `;` in clipboard text or text streams is not treated as a terminator), and holds the partial tail for the next read. Once each Message::Text is boundary-clean, the ping echo and any future writer can interleave safely. Adds protocol::last_instruction_boundary plus 11 unit tests covering empty buffers, partial frames, embedded `;`, multibyte truncation, and trailing garbage. Total test count is 218. Carry is force-flushed at 16 MiB to bound memory if upstream sends something pathological — well above any real instruction.
522 lines
17 KiB
Rust
522 lines
17 KiB
Rust
//! Guacamole protocol instruction encoding and decoding.
|
|
//!
|
|
//! Wire format: `LENGTH.ELEMENT,LENGTH.ELEMENT,...;`
|
|
//! Example: `4.size,3.800,3.600;`
|
|
|
|
use std::fmt;
|
|
|
|
/// A single Guacamole protocol instruction.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Instruction {
|
|
pub opcode: String,
|
|
pub args: Vec<String>,
|
|
}
|
|
|
|
impl Instruction {
|
|
pub fn new(opcode: impl Into<String>, args: Vec<String>) -> Self {
|
|
Self {
|
|
opcode: opcode.into(),
|
|
args,
|
|
}
|
|
}
|
|
|
|
/// Encode this instruction into Guacamole wire format.
|
|
pub fn encode(&self) -> String {
|
|
let mut out = encode_element(&self.opcode);
|
|
for arg in &self.args {
|
|
out.push(',');
|
|
out.push_str(&encode_element(arg));
|
|
}
|
|
out.push(';');
|
|
out
|
|
}
|
|
|
|
/// Parse a single instruction from a complete instruction string (including the trailing `;`).
|
|
pub fn parse(data: &str) -> Result<Self, ParseError> {
|
|
let data = data.trim_end_matches(';');
|
|
if data.is_empty() {
|
|
return Err(ParseError::Empty);
|
|
}
|
|
|
|
let mut elements = Vec::new();
|
|
let mut remaining = data;
|
|
|
|
loop {
|
|
// Parse length
|
|
let dot_pos = remaining.find('.').ok_or(ParseError::MalformedElement)?;
|
|
let len: usize = remaining[..dot_pos]
|
|
.parse()
|
|
.map_err(|_| ParseError::InvalidLength)?;
|
|
remaining = &remaining[dot_pos + 1..];
|
|
|
|
// Extract element value (length is in bytes per Guacamole spec)
|
|
if remaining.len() < len {
|
|
return Err(ParseError::Truncated);
|
|
}
|
|
if !remaining.is_char_boundary(len) {
|
|
return Err(ParseError::Truncated);
|
|
}
|
|
elements.push(remaining[..len].to_string());
|
|
remaining = &remaining[len..];
|
|
|
|
// Check for separator or end
|
|
if remaining.is_empty() {
|
|
break;
|
|
}
|
|
if remaining.starts_with(',') {
|
|
remaining = &remaining[1..];
|
|
} else {
|
|
return Err(ParseError::UnexpectedChar);
|
|
}
|
|
}
|
|
|
|
if elements.is_empty() {
|
|
return Err(ParseError::Empty);
|
|
}
|
|
|
|
let opcode = elements.remove(0);
|
|
Ok(Instruction {
|
|
opcode,
|
|
args: elements,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Instruction {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}", self.encode())
|
|
}
|
|
}
|
|
|
|
fn encode_element(s: &str) -> String {
|
|
// Length is the number of UTF-8 bytes (matching guacamole-common-js behavior
|
|
// for the server-side protocol; the JS side counts UTF-16 code units but
|
|
// guacd uses byte length).
|
|
format!("{}.{}", s.len(), s)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ParseError {
|
|
Empty,
|
|
MalformedElement,
|
|
InvalidLength,
|
|
Truncated,
|
|
UnexpectedChar,
|
|
}
|
|
|
|
impl fmt::Display for ParseError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
ParseError::Empty => write!(f, "empty instruction"),
|
|
ParseError::MalformedElement => write!(f, "malformed element (missing '.')"),
|
|
ParseError::InvalidLength => write!(f, "invalid length prefix"),
|
|
ParseError::Truncated => write!(f, "instruction truncated"),
|
|
ParseError::UnexpectedChar => write!(f, "unexpected character in instruction"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ParseError {}
|
|
|
|
/// Find the byte offset just past the last complete Guacamole instruction in
|
|
/// `buf`. Returns `None` if no complete instruction is present.
|
|
///
|
|
/// Walks element-by-element using the wire format's length prefix (which
|
|
/// counts UTF-8 characters per `guac_utf8_strlen` in libguac), so element
|
|
/// data containing literal `;` characters (clipboard text, text streams)
|
|
/// does not produce a false boundary.
|
|
///
|
|
/// On the first malformed byte it returns the boundary discovered so far
|
|
/// rather than scanning the rest of the buffer.
|
|
pub fn last_instruction_boundary(buf: &[u8]) -> Option<usize> {
|
|
let mut last_end: Option<usize> = None;
|
|
let mut pos = 0usize;
|
|
|
|
loop {
|
|
// Each instruction is 1+ comma-separated elements, terminated by ';'.
|
|
loop {
|
|
// Length prefix: ASCII decimal digits up to the '.' separator.
|
|
let len_start = pos;
|
|
while pos < buf.len() && buf[pos].is_ascii_digit() {
|
|
pos += 1;
|
|
}
|
|
if pos == len_start || pos >= buf.len() || buf[pos] != b'.' {
|
|
return last_end;
|
|
}
|
|
let len: usize = match std::str::from_utf8(&buf[len_start..pos])
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
{
|
|
Some(n) => n,
|
|
None => return last_end,
|
|
};
|
|
pos += 1; // skip '.'
|
|
|
|
// Skip `len` UTF-8 characters of element data.
|
|
let mut chars_skipped = 0;
|
|
while chars_skipped < len {
|
|
if pos >= buf.len() {
|
|
return last_end; // truncated mid-element
|
|
}
|
|
let b = buf[pos];
|
|
let char_len = if b < 0x80 {
|
|
1
|
|
} else if b < 0xC0 {
|
|
// Lone continuation byte — input is malformed UTF-8.
|
|
// Bail out with whatever boundary we've already proven.
|
|
return last_end;
|
|
} else if b < 0xE0 {
|
|
2
|
|
} else if b < 0xF0 {
|
|
3
|
|
} else {
|
|
4
|
|
};
|
|
if pos + char_len > buf.len() {
|
|
return last_end; // truncated multibyte
|
|
}
|
|
pos += char_len;
|
|
chars_skipped += 1;
|
|
}
|
|
|
|
// Element terminator.
|
|
if pos >= buf.len() {
|
|
return last_end;
|
|
}
|
|
match buf[pos] {
|
|
b',' => {
|
|
pos += 1;
|
|
// Continue inner loop: next element of this instruction.
|
|
}
|
|
b';' => {
|
|
pos += 1;
|
|
last_end = Some(pos);
|
|
break; // Out of element loop; start scanning next instruction.
|
|
}
|
|
_ => return last_end,
|
|
}
|
|
}
|
|
|
|
// Falls through on `;`: continue 'instructions to scan more.
|
|
if pos >= buf.len() {
|
|
return last_end;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Streaming parser that accumulates data and yields complete instructions.
|
|
#[derive(Default)]
|
|
pub struct InstructionParser {
|
|
buffer: String,
|
|
}
|
|
|
|
impl InstructionParser {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Feed data into the parser and return any complete instructions.
|
|
pub fn receive(&mut self, data: &str) -> Vec<Result<Instruction, ParseError>> {
|
|
self.buffer.push_str(data);
|
|
|
|
if self.buffer.len() > 1_048_576 {
|
|
self.buffer.clear();
|
|
return vec![];
|
|
}
|
|
|
|
let mut results = Vec::new();
|
|
|
|
while let Some(semi_pos) = self.buffer.find(';') {
|
|
let instruction_str = self.buffer[..semi_pos].to_string();
|
|
self.buffer = self.buffer[semi_pos + 1..].to_string();
|
|
results.push(Instruction::parse(&instruction_str));
|
|
}
|
|
|
|
results
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_encode_simple() {
|
|
let inst = Instruction::new("size", vec!["800".into(), "600".into()]);
|
|
assert_eq!(inst.encode(), "4.size,3.800,3.600;");
|
|
}
|
|
|
|
#[test]
|
|
fn test_encode_no_args() {
|
|
let inst = Instruction::new("nop", vec![]);
|
|
assert_eq!(inst.encode(), "3.nop;");
|
|
}
|
|
|
|
#[test]
|
|
fn test_encode_select() {
|
|
let inst = Instruction::new("select", vec!["ssh".into()]);
|
|
assert_eq!(inst.encode(), "6.select,3.ssh;");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_simple() {
|
|
let inst = Instruction::parse("4.size,3.800,3.600").unwrap();
|
|
assert_eq!(inst.opcode, "size");
|
|
assert_eq!(inst.args, vec!["800", "600"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_with_semicolon() {
|
|
let inst = Instruction::parse("4.size,3.800,3.600;").unwrap();
|
|
assert_eq!(inst.opcode, "size");
|
|
assert_eq!(inst.args, vec!["800", "600"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_no_args() {
|
|
let inst = Instruction::parse("3.nop").unwrap();
|
|
assert_eq!(inst.opcode, "nop");
|
|
assert!(inst.args.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_roundtrip() {
|
|
let original = Instruction::new(
|
|
"connect",
|
|
vec![
|
|
"10.0.0.5".into(),
|
|
"22".into(),
|
|
"admin".into(),
|
|
"password123".into(),
|
|
],
|
|
);
|
|
let encoded = original.encode();
|
|
let parsed = Instruction::parse(&encoded).unwrap();
|
|
assert_eq!(original, parsed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_streaming_parser() {
|
|
let mut parser = InstructionParser::new();
|
|
|
|
// Feed partial data
|
|
let results = parser.receive("4.size,3.80");
|
|
assert!(results.is_empty());
|
|
|
|
// Complete the instruction and start another
|
|
let results = parser.receive("0,3.600;3.nop;");
|
|
assert_eq!(results.len(), 2);
|
|
assert_eq!(results[0].as_ref().unwrap().opcode, "size");
|
|
assert_eq!(results[1].as_ref().unwrap().opcode, "nop");
|
|
}
|
|
|
|
// ── Adversarial parser cases ───────────────────────────────────────────
|
|
// guacd is a trust boundary: even under compromise, the rustguac parser
|
|
// must refuse to panic, must reject malformed frames cleanly, and must
|
|
// bound memory. Every negative case here should produce a ParseError or
|
|
// be dropped by the 1 MiB buffer cap — never a panic or OOM.
|
|
|
|
#[test]
|
|
fn parse_rejects_length_longer_than_data() {
|
|
// Claims 10 bytes but only 3 are present.
|
|
let err = Instruction::parse("10.abc").unwrap_err();
|
|
assert!(matches!(err, ParseError::Truncated));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_non_numeric_length() {
|
|
let err = Instruction::parse("x.size").unwrap_err();
|
|
assert!(matches!(err, ParseError::InvalidLength));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_negative_length() {
|
|
// `-1` is not valid usize — must be rejected, not cast.
|
|
let err = Instruction::parse("-1.x").unwrap_err();
|
|
assert!(matches!(err, ParseError::InvalidLength));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_overflow_length() {
|
|
// 2^65 overflows usize on every platform and must be a clean error.
|
|
let err = Instruction::parse("36893488147419103232.x").unwrap_err();
|
|
assert!(matches!(err, ParseError::InvalidLength));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_missing_dot() {
|
|
let err = Instruction::parse("4size").unwrap_err();
|
|
assert!(matches!(err, ParseError::MalformedElement));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_unexpected_separator() {
|
|
// After the element body we must see `,` or end. A bare letter
|
|
// should produce UnexpectedChar, not silently continue.
|
|
let err = Instruction::parse("4.sizeX3.800").unwrap_err();
|
|
assert!(matches!(err, ParseError::UnexpectedChar));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_empty() {
|
|
assert!(matches!(
|
|
Instruction::parse("").unwrap_err(),
|
|
ParseError::Empty
|
|
));
|
|
assert!(matches!(
|
|
Instruction::parse(";").unwrap_err(),
|
|
ParseError::Empty
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_split_multibyte_char() {
|
|
// '€' is three bytes (E2 82 AC). Claiming length 2 would split the
|
|
// char on a non-boundary — parser must reject rather than panic.
|
|
let bad = "2.€";
|
|
let err = Instruction::parse(bad).unwrap_err();
|
|
assert!(matches!(err, ParseError::Truncated));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_accepts_correct_multibyte_length() {
|
|
// Correct byte-length for '€' is 3.
|
|
let inst = Instruction::parse("3.€").unwrap();
|
|
assert_eq!(inst.opcode, "€");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_accepts_zero_length_element() {
|
|
let inst = Instruction::parse("3.nop,0.,3.foo").unwrap();
|
|
assert_eq!(inst.opcode, "nop");
|
|
assert_eq!(inst.args, vec!["", "foo"]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_accepts_embedded_semicolon_within_length() {
|
|
// Length includes the `;` so it's part of the element, not a
|
|
// terminator. The caller's framing must respect length; this test
|
|
// proves parse() honours the length over the semicolon.
|
|
let inst = Instruction::parse("5.a;b;c").unwrap();
|
|
assert_eq!(inst.opcode, "a;b;c");
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_parser_caps_buffer_at_1_mib() {
|
|
// Feed > 1 MiB in a single receive with no terminator. The buffer
|
|
// must clear in-place rather than grow unbounded, and the call
|
|
// must return cleanly without panicking. After the clear, a fresh
|
|
// well-formed frame parses normally.
|
|
let mut parser = InstructionParser::new();
|
|
let huge = "x".repeat(1_100_000);
|
|
let out = parser.receive(&huge);
|
|
// Over-cap input is dropped entirely (no partial instruction yield).
|
|
assert!(out.is_empty());
|
|
// Fresh input parses correctly — buffer is empty, no residue.
|
|
let out2 = parser.receive("3.nop;");
|
|
assert_eq!(out2.len(), 1);
|
|
assert_eq!(out2[0].as_ref().unwrap().opcode, "nop");
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_parser_split_at_every_boundary() {
|
|
// Feed a well-formed frame byte-by-byte; the parser must assemble
|
|
// correctly regardless of where chunks break.
|
|
let full = "4.size,3.800,3.600;3.nop;";
|
|
let mut parser = InstructionParser::new();
|
|
let mut all = Vec::new();
|
|
for ch in full.chars() {
|
|
let mut buf = [0u8; 4];
|
|
let s = ch.encode_utf8(&mut buf);
|
|
all.extend(parser.receive(s));
|
|
}
|
|
assert_eq!(all.len(), 2);
|
|
assert_eq!(all[0].as_ref().unwrap().opcode, "size");
|
|
assert_eq!(all[1].as_ref().unwrap().opcode, "nop");
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_parser_emits_error_for_malformed_frame() {
|
|
// A malformed-but-terminated frame yields a Result::Err, not a panic.
|
|
let mut parser = InstructionParser::new();
|
|
let out = parser.receive("not-a-valid-instruction;3.nop;");
|
|
assert_eq!(out.len(), 2);
|
|
assert!(out[0].is_err());
|
|
assert_eq!(out[1].as_ref().unwrap().opcode, "nop");
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_empty_buffer() {
|
|
assert_eq!(last_instruction_boundary(b""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_complete_instruction() {
|
|
let s = b"4.size,3.800,3.600;";
|
|
assert_eq!(last_instruction_boundary(s), Some(s.len()));
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_partial_no_terminator() {
|
|
assert_eq!(last_instruction_boundary(b"4.size,3.80"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_two_instructions_first_complete() {
|
|
let s = b"4.size,3.800,3.600;3.foo";
|
|
assert_eq!(last_instruction_boundary(s), Some(19));
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_two_instructions_both_complete() {
|
|
let s = b"4.size,3.800,3.600;3.nop;";
|
|
assert_eq!(last_instruction_boundary(s), Some(s.len()));
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_ignores_semicolon_inside_element_value() {
|
|
// Clipboard text containing `;`: the embedded `;` MUST NOT be treated
|
|
// as an instruction terminator. Length prefix 11 covers "hello;world".
|
|
let s = b"9.clipboard,1.0,11.hello;world;";
|
|
assert_eq!(last_instruction_boundary(s), Some(s.len()));
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_partial_when_split_inside_element_data() {
|
|
// Reader landed mid-clipboard-element: only "hello;" of the 11-char
|
|
// value is present. The trailing `;` is data, not a terminator.
|
|
let s = b"9.clipboard,1.0,11.hello;";
|
|
assert_eq!(last_instruction_boundary(s), None);
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_empty_opcode_ping() {
|
|
// `0.,4.ping,13.1234567890123;` — empty opcode (length 0) is valid.
|
|
let s = b"0.,4.ping,13.1234567890123;";
|
|
assert_eq!(last_instruction_boundary(s), Some(s.len()));
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_handles_utf8_multibyte_in_element() {
|
|
// "café" — 4 UTF-8 chars, 5 bytes. Length prefix counts characters.
|
|
let s = "9.clipboard,1.0,4.café;".as_bytes();
|
|
assert_eq!(last_instruction_boundary(s), Some(s.len()));
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_truncated_mid_multibyte() {
|
|
// "9.clipboard,1.0,4.caf" + first byte of "é" only — must not advance.
|
|
let mut s = b"9.clipboard,1.0,4.caf".to_vec();
|
|
s.push(0xC3); // first byte of `é` (UTF-8 0xC3 0xA9)
|
|
assert_eq!(last_instruction_boundary(&s), None);
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_returns_last_complete_when_trailing_garbage() {
|
|
// First instruction is complete; what follows is malformed. We
|
|
// should still report the first boundary so the proxy can flush it.
|
|
let s = b"3.nop;garbage";
|
|
assert_eq!(last_instruction_boundary(s), Some(6));
|
|
}
|
|
}
|