diff --git a/Cargo.lock b/Cargo.lock index 952aedb..e2978e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,12 +274,6 @@ dependencies = [ "unicode-id", ] -[[package]] -name = "maybe-uninit" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" - [[package]] name = "memchr" version = "2.6.4" @@ -355,15 +349,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "read_char" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f803565a87502a15ef665d375e341c657de382dbf11c0eddca6b15bb82329ab" -dependencies = [ - "smallvec", -] - [[package]] name = "regex" version = "1.10.5" @@ -447,20 +432,10 @@ dependencies = [ name = "slim_protocol" version = "0.2.0" dependencies = [ - "read_char", "thiserror", "ulid", ] -[[package]] -name = "smallvec" -version = "0.6.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0" -dependencies = [ - "maybe-uninit", -] - [[package]] name = "strsim" version = "0.10.0" diff --git a/rust_slim/src/server.rs b/rust_slim/src/server.rs index a7de03b..b4e0632 100644 --- a/rust_slim/src/server.rs +++ b/rust_slim/src/server.rs @@ -74,6 +74,15 @@ impl SlimServer { let mut results = Vec::new(); for instruction in instructions { match instruction { + Instruction::Malformed { id, fields } => { + results.push(InstructionResult::exception( + id, + ExceptionMessage::new(format!( + "MALFORMED_INSTRUCTION [{}]", + fields.join(",") + )), + )); + } Instruction::Import { id, path } => { self.imports.push(path); results.push(InstructionResult::ok(id)) @@ -228,7 +237,7 @@ impl SlimServer { #[cfg(test)] mod tests { - use slim_protocol::Id; + use slim_protocol::{FromSlimReader, Id, ToSlimString}; use std::error::Error; use std::io::Cursor; @@ -476,6 +485,35 @@ mod tests { Ok(()) } + #[test] + fn malformed_instruction_returns_an_exception_and_keeps_the_batch_running( + ) -> Result<(), Box> { + let mut input = vec![ + vec!["bad_id", "unknown", "field"], + vec!["good_id", "import", "TestPath"], + ] + .to_slim_string() + .as_bytes() + .to_vec(); + input.extend_from_slice("bye".to_slim_string().as_bytes()); + + let mut output = Vec::new(); + SlimServer::new(Cursor::new(input), Cursor::new(&mut output)).run()?; + + let mut response = Cursor::new(&output[b"Slim -- V0.5\n".len()..]); + assert_eq!( + vec![ + InstructionResult::exception( + Id::from("bad_id"), + ExceptionMessage::new("MALFORMED_INSTRUCTION [bad_id,unknown,field]".into()), + ), + InstructionResult::ok(Id::from("good_id")), + ], + Vec::::from_reader(&mut response)? + ); + Ok(()) + } + #[test] fn parse_symbol_with_no_symbol() -> Result<(), Box> { let mut vec = Vec::new(); diff --git a/rust_slim/src/to_slim_result_string.rs b/rust_slim/src/to_slim_result_string.rs index 14a3c66..6971e87 100644 --- a/rust_slim/src/to_slim_result_string.rs +++ b/rust_slim/src/to_slim_result_string.rs @@ -37,7 +37,7 @@ impl ToSlimResultString for () { } } -impl<'a> ToSlimResultString for &'a str { +impl ToSlimResultString for &str { fn to_slim_result_string(self) -> Result { Ok(self.to_string()) } diff --git a/slim_protocol/Cargo.toml b/slim_protocol/Cargo.toml index d310299..5ba41ff 100644 --- a/slim_protocol/Cargo.toml +++ b/slim_protocol/Cargo.toml @@ -10,4 +10,3 @@ description = "General Slim Protocol stuff. Used internally by rust_slim and Tem [dependencies] ulid = "1" thiserror = "1" -read_char = "0.1" diff --git a/slim_protocol/src/lib.rs b/slim_protocol/src/lib.rs index de84398..c6d2982 100644 --- a/slim_protocol/src/lib.rs +++ b/slim_protocol/src/lib.rs @@ -150,8 +150,24 @@ impl Display for Id { } } +/// The recursive value model used by the SliM wire protocol. +/// +/// Runtime-only values such as fixture objects are deliberately not represented +/// here; they belong to the execution layer. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum SlimValue { + String(String), + List(Vec), +} + #[derive(Debug, PartialEq, Eq, Clone)] pub enum Instruction { + /// An instruction list with an unknown operation or an invalid number of + /// fields. The server must reply to this item and keep processing its batch. + Malformed { + id: Id, + fields: Vec, + }, Import { id: Id, path: String, @@ -160,12 +176,16 @@ pub enum Instruction { id: Id, instance: String, class: String, + /// Instruction arguments remain string-only until the typed runtime + /// value model is introduced. args: Vec, }, Call { id: Id, instance: String, function: String, + /// Instruction arguments remain string-only until the typed runtime + /// value model is introduced. args: Vec, }, #[allow(dead_code)] @@ -174,6 +194,8 @@ pub enum Instruction { symbol: String, instance: String, function: String, + /// Instruction arguments remain string-only until the typed runtime + /// value model is introduced. args: Vec, }, #[allow(dead_code)] diff --git a/slim_protocol/src/slim_deserialize.rs b/slim_protocol/src/slim_deserialize.rs index 88cb9b0..36de28e 100644 --- a/slim_protocol/src/slim_deserialize.rs +++ b/slim_protocol/src/slim_deserialize.rs @@ -1,15 +1,16 @@ -use read_char::read_next_char; use std::io::{self, BufRead, Cursor}; -use thiserror::Error; -use crate::{ExceptionMessage, Instruction, InstructionResultValue}; +use thiserror::Error; -use super::{ByeOrSlimInstructions, Id, InstructionResult}; +use crate::{ + ByeOrSlimInstructions, ExceptionMessage, Id, Instruction, InstructionResult, + InstructionResultValue, SlimValue, +}; #[derive(Debug, Error)] pub enum FromSlimReaderError { #[error(transparent)] - IoError(#[from] std::io::Error), + IoError(#[from] io::Error), #[error("{0}")] Other(String), } @@ -22,9 +23,7 @@ pub trait FromSlimReader { impl FromSlimReader for String { fn from_reader(reader: &mut impl BufRead) -> Result { - let len = read_len(reader)?; - let string = reader.read_n_chars(len)?; - Ok(string) + read_outer_payload(reader) } } @@ -45,379 +44,505 @@ where T: FromSlimReader, { fn from_reader(reader: &mut impl BufRead) -> Result { - let _ = read_len(reader)?; // TODO: Validate this len against the read bytes - let mut result = Vec::new(); - reader.read_expected_char('[')?; - let n_elements = read_len(reader)?; - for _ in 0..n_elements { - result.push(T::from_reader(reader)?); - reader.read_expected_char(':')?; - } - reader.read_expected_char(']')?; - Ok(result) + let payload = read_outer_payload(reader)?; + parse_list_payload(&payload)? + .into_iter() + .map(|item| T::from_reader(&mut Cursor::new(outer_frame(item)))) + .collect() + } +} + +impl FromSlimReader for SlimValue { + fn from_reader(reader: &mut impl BufRead) -> Result { + parse_slim_value(read_outer_payload(reader)?) } } impl FromSlimReader for InstructionResultValue { fn from_reader(reader: &mut impl BufRead) -> Result { - let value = String::from_reader(reader)?; - Ok(match value.as_str() { - "OK" => InstructionResultValue::Ok, - "/__VOID__/" => InstructionResultValue::Void, - value if value.starts_with('[') => { - let values: Vec = - Vec::from_reader(&mut Cursor::new(value))?; - InstructionResultValue::List(values) - } - other => { - if let Some(message) = other.strip_prefix("__EXCEPTION__:") { - InstructionResultValue::Exception(ExceptionMessage::new(message.into())) - } else { - InstructionResultValue::String(other.into()) - } - } - }) + parse_instruction_result_value(read_outer_payload(reader)?) } } impl FromSlimReader for InstructionResult { fn from_reader(reader: &mut impl BufRead) -> Result { - let [id, value] = <[String; 2]>::from_reader(reader)?; - let id = Id::from(id); - Ok(InstructionResult { - id, - value: match value.as_str() { - "OK" => InstructionResultValue::Ok, - "/__VOID__/" => InstructionResultValue::Void, - value if value.starts_with('[') => { - let value = format!("{:0>6}:{}", value.len(), value); - let values: Vec = - Vec::from_reader(&mut Cursor::new(value))?; - InstructionResultValue::List(values) - } - other => { - if let Some(message) = other.strip_prefix("__EXCEPTION__:") { - InstructionResultValue::Exception(ExceptionMessage::new(message.into())) - } else { - InstructionResultValue::String(other.into()) - } - } - }, - }) + let values = parse_list_payload(&read_outer_payload(reader)?)?; + instruction_result_from_values(values) } } impl FromSlimReader for Instruction { - fn from_reader(reader: &mut impl BufRead) -> Result - where - Self: Sized, - { - let mut data: Vec = Vec::from_reader(reader)?; - data.reverse(); - - let id = Id::from( - data.pop() - .ok_or(FromSlimReaderError::Other("Expected data".into()))?, - ); - match data - .pop() - .ok_or(FromSlimReaderError::Other("Expectd instruction".into()))? - .as_str() - { - "import" => { - let path = data - .pop() - .ok_or(FromSlimReaderError::Other("Expected path".into()))?; - Ok(Instruction::Import { id, path }) - } - "make" => { - let instance = data - .pop() - .ok_or(FromSlimReaderError::Other("Expected instance".into()))?; - let class = data - .pop() - .ok_or(FromSlimReaderError::Other("Expected class".into()))?; - data.reverse(); - Ok(Instruction::Make { - id, - instance, - class, - args: data, - }) - } - "call" => { - let instance = data - .pop() - .ok_or(FromSlimReaderError::Other("Expected instance".into()))?; - let function = data - .pop() - .ok_or(FromSlimReaderError::Other("Expected function".into()))?; - data.reverse(); - Ok(Instruction::Call { - id, - instance, - function, - args: data, - }) - } - "callAndAssign" => { - let symbol = data - .pop() - .ok_or(FromSlimReaderError::Other("Expected symbol".into()))?; - let instance = data - .pop() - .ok_or(FromSlimReaderError::Other("Expected instance".into()))?; - let function = data - .pop() - .ok_or(FromSlimReaderError::Other("Expected function".into()))?; - data.reverse(); - Ok(Instruction::CallAndAssign { - id, - instance, - function, - symbol, - args: data, - }) - } - "assign" => { - let symbol = data - .pop() - .ok_or(FromSlimReaderError::Other("Expected symbol".into()))?; - let value = data - .pop() - .ok_or(FromSlimReaderError::Other("Expected value".into()))?; - Ok(Instruction::Assign { id, symbol, value }) - } - other => todo!("Not implemented {other}"), - } + fn from_reader(reader: &mut impl BufRead) -> Result { + Ok(instruction_from_values(parse_list_payload( + &read_outer_payload(reader)?, + )?)) } } impl FromSlimReader for ByeOrSlimInstructions { - fn from_reader(reader: &mut impl BufRead) -> Result - where - Self: Sized, - { - let _ = read_len(reader)?; - match reader.read_char()? { - '[' => { - let mut result = Vec::new(); - let n_elements = read_len(reader)?; - for _ in 0..n_elements { - result.push(Instruction::from_reader(reader)?); - reader.read_expected_char(':')?; - } - reader.read_expected_char(']')?; - Ok(ByeOrSlimInstructions::Instructions(result)) - } - 'b' => { - reader.read_expected_char('y')?; - reader.read_expected_char('e')?; - Ok(ByeOrSlimInstructions::Bye) - } - other => Err(FromSlimReaderError::Other(format!( - "Non expected byte {other}" - ))), + fn from_reader(reader: &mut impl BufRead) -> Result { + let payload = read_outer_payload(reader)?; + if payload == "bye" { + return Ok(Self::Bye); } + + let instructions = parse_list_payload(&payload)? + .into_iter() + .map(|instruction| parse_list_payload(&instruction).map(instruction_from_values)) + .collect::, _>>()?; + Ok(Self::Instructions(instructions)) } } -trait ReadChar { - fn read_char(&mut self) -> Result; - fn read_n_chars(&mut self, n: usize) -> Result { - let mut buffer = String::new(); - buffer.reserve(n); - for _ in 0..n { - buffer.push(self.read_char()?); +fn instruction_result_from_values( + values: Vec, +) -> Result { + let [id, value]: [String; 2] = values.try_into().map_err(|_| { + FromSlimReaderError::Other("Expected an instruction result with an id and a value".into()) + })?; + Ok(InstructionResult { + id: Id::from(id), + value: parse_instruction_result_value(value)?, + }) +} + +fn parse_instruction_result_value( + value: String, +) -> Result { + Ok(match parse_slim_value(value)? { + SlimValue::List(values) => InstructionResultValue::List( + values + .into_iter() + .map(slim_value_to_instruction_result_value) + .collect(), + ), + SlimValue::String(value) if value == "OK" => InstructionResultValue::Ok, + SlimValue::String(value) if value == "/__VOID__/" => InstructionResultValue::Void, + SlimValue::String(value) => { + if let Some(message) = value.strip_prefix("__EXCEPTION__:") { + InstructionResultValue::Exception(ExceptionMessage::new(message.into())) + } else { + InstructionResultValue::String(value) + } } - Ok(buffer) + }) +} + +fn slim_value_to_instruction_result_value(value: SlimValue) -> InstructionResultValue { + match value { + SlimValue::List(values) => InstructionResultValue::List( + values + .into_iter() + .map(slim_value_to_instruction_result_value) + .collect(), + ), + SlimValue::String(value) => InstructionResultValue::String(value), } - fn read_expected_char(&mut self, expected_char: char) -> Result<(), std::io::Error> { - let char = self.read_char()?; - if char == expected_char { - Ok(()) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Expected {expected_char} but got {char}"), - )) +} + +fn parse_slim_value(value: String) -> Result { + if value.starts_with('[') { + if let Ok(values) = parse_list_payload(&value) { + return Ok(SlimValue::List( + values + .into_iter() + .map(parse_slim_value) + .collect::>()?, + )); } } + Ok(SlimValue::String(value)) } -impl ReadChar for R -where - R: BufRead, -{ - fn read_char(&mut self) -> Result { - read_next_char(self).map_err(|err| match err { - read_char::Error::NotAnUtf8(_) => { - std::io::Error::new(std::io::ErrorKind::InvalidData, "Non UTF-8 character") - } - read_char::Error::Io(err) => err, - read_char::Error::EOF => std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "Unexpected end of stream", - ), - }) +fn instruction_from_values(fields: Vec) -> Instruction { + let id = Id::from(fields.first().cloned().unwrap_or_default()); + let malformed = || Instruction::Malformed { + id: id.clone(), + fields: fields.clone(), + }; + let Some(operation) = fields.get(1).map(String::as_str) else { + return malformed(); + }; + + match operation { + "import" if fields.len() == 3 => Instruction::Import { + id, + path: fields[2].clone(), + }, + "make" if fields.len() >= 4 => Instruction::Make { + id, + instance: fields[2].clone(), + class: fields[3].clone(), + args: fields[4..].to_vec(), + }, + "call" if fields.len() >= 4 => Instruction::Call { + id, + instance: fields[2].clone(), + function: fields[3].clone(), + args: fields[4..].to_vec(), + }, + "callAndAssign" if fields.len() >= 5 => Instruction::CallAndAssign { + id, + symbol: fields[2].clone(), + instance: fields[3].clone(), + function: fields[4].clone(), + args: fields[5..].to_vec(), + }, + "assign" if fields.len() == 4 => Instruction::Assign { + id, + symbol: fields[2].clone(), + value: fields[3].clone(), + }, + _ => malformed(), + } +} + +/// Reads a complete message. The outer length is counted in UTF-8 bytes. +fn read_outer_payload(reader: &mut impl BufRead) -> Result { + let length = read_length_prefix(reader)?; + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes)?; + String::from_utf8(bytes) + .map_err(|_| FromSlimReaderError::Other("Message payload is not valid UTF-8".into())) +} + +fn outer_frame(payload: String) -> Vec { + format!("{:06}:{}", payload.len(), payload).into_bytes() +} + +fn read_length_prefix(reader: &mut impl BufRead) -> Result { + let mut prefix = Vec::new(); + reader.read_until(b':', &mut prefix)?; + if prefix.last() != Some(&b':') { + return Err(FromSlimReaderError::Other( + "Missing message length terminator".into(), + )); + } + parse_length(&prefix[..prefix.len() - 1]) +} + +fn parse_list_payload(payload: &str) -> Result, FromSlimReaderError> { + let mut position = 0; + expect_byte(payload, &mut position, b'[')?; + let count = parse_length_at(payload, &mut position)?; + let mut values = Vec::new(); + for _ in 0..count { + values.push(parse_utf16_string(payload, &mut position)?); + expect_byte(payload, &mut position, b':')?; + } + expect_byte(payload, &mut position, b']')?; + if position != payload.len() { + return Err(FromSlimReaderError::Other( + "Trailing data after SliM list terminator".into(), + )); + } + Ok(values) +} + +fn parse_utf16_string(payload: &str, position: &mut usize) -> Result { + let length = parse_length_at(payload, position)?; + if length == 0 { + return Ok(String::new()); } + let start = *position; + let mut utf16_units = 0; + for (offset, character) in payload[start..].char_indices() { + utf16_units += character.len_utf16(); + if utf16_units == length { + let end = start + offset + character.len_utf8(); + *position = end; + return Ok(payload[start..end].into()); + } + if utf16_units > length { + return Err(FromSlimReaderError::Other( + "SliM string length ends in the middle of a UTF-16 character".into(), + )); + } + } + Err(FromSlimReaderError::Other( + "SliM string is shorter than its declared UTF-16 length".into(), + )) } -fn read_len(reader: &mut impl BufRead) -> Result { - let mut buffer = Vec::new(); - buffer.reserve_exact(6); - reader.read_until(b':', &mut buffer)?; - if buffer.len() < 6 { - return Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "Failure reading from Slim Server", +fn parse_length_at(payload: &str, position: &mut usize) -> Result { + let rest = &payload[*position..]; + let Some(end) = rest.find(':') else { + return Err(FromSlimReaderError::Other( + "Missing SliM length terminator".into(), + )); + }; + let prefix = &rest[..end]; + *position += end + 1; + parse_length(prefix.as_bytes()) +} + +fn parse_length(prefix: &[u8]) -> Result { + if prefix.len() < 6 || !prefix.iter().all(u8::is_ascii_digit) { + return Err(FromSlimReaderError::Other( + "SliM lengths must contain at least six ASCII digits".into(), )); } - String::from_utf8_lossy(&buffer[..buffer.len() - 1]) + std::str::from_utf8(prefix) + .expect("ASCII digits are valid UTF-8") .parse() - .map_err(|_| { - std::io::Error::new( - io::ErrorKind::InvalidData, - "Failure converting read data to a number", - ) - }) + .map_err(|_| FromSlimReaderError::Other("SliM length is out of range".into())) +} + +fn expect_byte( + payload: &str, + position: &mut usize, + expected: u8, +) -> Result<(), FromSlimReaderError> { + match payload.as_bytes().get(*position) { + Some(actual) if *actual == expected => { + *position += 1; + Ok(()) + } + Some(actual) => Err(FromSlimReaderError::Other(format!( + "Expected {} but got {}", + expected as char, *actual as char + ))), + None => Err(FromSlimReaderError::Other(format!( + "Expected {} but reached the end of the SliM list", + expected as char + ))), + } } #[cfg(test)] mod test { - use super::*; use std::error::Error; - use std::io::Cursor; + use std::io::{Cursor, Read}; + + use super::*; + use crate::ToSlimString; #[test] - fn read_empty_string() -> Result<(), Box> { + fn reads_unicode_using_utf16_lengths() -> Result<(), Box> { assert_eq!( - String::new(), - String::from_reader(&mut Cursor::new("000000:"))? + vec!["รฉ".to_string(), "๐Ÿ˜€".into()], + Vec::::from_reader(&mut Cursor::new("000031:[000002:000001:รฉ:000002:๐Ÿ˜€:]"))? ); Ok(()) } #[test] - fn read_string() -> Result<(), Box> { + fn reads_empty_strings_and_rejects_partial_surrogate_lengths() -> Result<(), Box> { assert_eq!( - String::from("Hello world"), - String::from_reader(&mut Cursor::new("000011:Hello world"))? + vec![String::new()], + Vec::::from_reader(&mut Cursor::new("000017:[000001:000000::]"))? ); + let error = Vec::::from_reader(&mut Cursor::new("000021:[000001:000001:๐Ÿ˜€:]")) + .expect_err("an astral character needs two UTF-16 code units"); + assert!(error.to_string().contains("middle of a UTF-16 character")); Ok(()) } #[test] - fn read_string_with_special_chars() -> Result<(), Box> { + fn rejects_wrong_list_size_and_terminators() { + Vec::::from_reader(&mut Cursor::new("000018:[000002:000001:a:]")) + .expect_err("list count must match"); + + let err = Vec::::from_reader(&mut Cursor::new("000018:[000001:000001:a;]")) + .expect_err("item terminator must be a colon") + .to_string(); + assert_eq!("Expected : but got ;", err); + } + + #[test] + fn recursive_result_lists_round_trip() -> Result<(), Box> { + let result = InstructionResult { + id: Id::from("id"), + value: InstructionResultValue::List(vec![ + InstructionResultValue::String("รฉ".into()), + InstructionResultValue::List(vec![InstructionResultValue::String("๐Ÿ˜€".into())]), + ]), + }; + let wire = result.to_slim_string(); assert_eq!( - String::from("Tipo de evento invรกlido"), - String::from_reader(&mut Cursor::new("000023:Tipo de evento invรกlido"))? + result, + InstructionResult::from_reader(&mut Cursor::new(wire.as_bytes()))? ); Ok(()) } #[test] - fn read_empty_vec() -> Result<(), Box> { + fn recursive_wire_values_round_trip() -> Result<(), Box> { + let value = SlimValue::List(vec![ + SlimValue::String("รฉ".into()), + SlimValue::List(vec![SlimValue::String("๐Ÿ˜€".into())]), + ]); + let wire = value.to_slim_string(); assert_eq!( - Vec::::new(), - Vec::::from_reader(&mut Cursor::new("000009:[000000:]"))? + value, + SlimValue::from_reader(&mut Cursor::new(wire.as_bytes()))? ); Ok(()) } #[test] - fn read_vec() -> Result<(), Box> { + fn fragmented_reads_work() -> Result<(), Box> { + struct OneByteReader(Cursor>); + impl Read for OneByteReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let mut byte = [0]; + let read = self.0.read(&mut byte)?; + if read == 0 { + return Ok(0); + } + buffer[0] = byte[0]; + Ok(1) + } + } + let input = vec!["๐Ÿ˜€".to_string()].to_slim_string().as_bytes().to_vec(); + let mut reader = io::BufReader::new(OneByteReader(Cursor::new(input))); assert_eq!( - vec!["Element1".to_string(), "Element2".into()], - Vec::::from_reader(&mut Cursor::new( - "000041:[000002:000008:Element1:000008:Element2:]" - ))? + vec!["๐Ÿ˜€".to_string()], + Vec::::from_reader(&mut reader)? ); Ok(()) } #[test] - fn read_with_incorrect_values() -> Result<(), Box> { - let err = Vec::::from_reader(&mut Cursor::new("000009:[000000:A]")) - .expect_err("Expected error") - .to_string(); - assert_eq!("Expected ] but got A", err); + fn malformed_instruction_retains_its_id_and_raw_fields() -> Result<(), Box> { + let message = vec![ + vec!["unknown_id", "unknown", "field"], + vec!["short_id", "call", "fixture"], + ] + .to_slim_string(); + assert_eq!( + ByeOrSlimInstructions::Instructions(vec![ + Instruction::Malformed { + id: Id::from("unknown_id"), + fields: vec!["unknown_id".into(), "unknown".into(), "field".into()], + }, + Instruction::Malformed { + id: Id::from("short_id"), + fields: vec!["short_id".into(), "call".into(), "fixture".into()], + }, + ]), + ByeOrSlimInstructions::from_reader(&mut Cursor::new(message.as_bytes()))? + ); Ok(()) } #[test] - fn read_instruction_result() -> Result<(), Box> { - let id = Id::from("01HFM0NQM3ZS6BBX0ZH6VA6DJX"); - assert_eq!( - InstructionResult { - id: id.clone(), - value: InstructionResultValue::Ok + fn all_instruction_forms_round_trip() -> Result<(), Box> { + let instructions = vec![ + Instruction::Import { + id: Id::from("import"), + path: "Fixtures".into(), }, - InstructionResult::from_reader(&mut Cursor::new( - "000053:[000002:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000002:OK:]" - ))? - ); - assert_eq!( - InstructionResult { - id: id.clone(), - value: InstructionResultValue::Void + Instruction::Make { + id: Id::from("make"), + instance: "fixture".into(), + class: "Calculator".into(), + args: vec!["1".into()], }, - InstructionResult::from_reader(&mut Cursor::new( - "000061:[000002:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000010:/__VOID__/:]" - ))? - ); - assert_eq!( - InstructionResult { - id: id.clone(), - value: InstructionResultValue::String("null".to_string()), + Instruction::Call { + id: Id::from("call"), + instance: "fixture".into(), + function: "value".into(), + args: vec!["รฉ".into()], + }, + Instruction::CallAndAssign { + id: Id::from("call-and-assign"), + symbol: "answer".into(), + instance: "fixture".into(), + function: "answer".into(), + args: vec!["๐Ÿ˜€".into()], + }, + Instruction::Assign { + id: Id::from("assign"), + symbol: "value".into(), + value: "42".into(), }, - InstructionResult::from_reader(&mut Cursor::new( - "000055:[000002:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000004:null:]" - ))? + ]; + let wire = instructions.to_slim_string(); + assert_eq!( + ByeOrSlimInstructions::Instructions(instructions), + ByeOrSlimInstructions::from_reader(&mut Cursor::new(wire.as_bytes()))? ); + Ok(()) + } + + #[test] + fn structurally_invalid_instruction_list_is_a_wire_error() { + let malformed = ["[000002:000002:id;]"].to_slim_string(); + ByeOrSlimInstructions::from_reader(&mut Cursor::new(malformed.as_bytes())) + .expect_err("invalid nested list syntax must fail at the wire layer"); + } + + #[test] + fn a_string_starting_with_a_bracket_is_not_always_a_list() -> Result<(), Box> { + let value = SlimValue::String("[literal text".into()); + let wire = value.to_slim_string(); assert_eq!( - InstructionResult { - id: id.clone(), - value: InstructionResultValue::String("Value".to_string()), - }, - InstructionResult::from_reader(&mut Cursor::new( - "000056:[000002:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000005:Value:]" - ))? + value, + SlimValue::from_reader(&mut Cursor::new(wire.as_bytes()))? ); + Ok(()) + } + + #[test] + fn recursive_response_lists_keep_control_markers_as_strings() -> Result<(), Box> { + let response = InstructionResult { + id: Id::from("id"), + value: InstructionResultValue::List(vec![ + InstructionResultValue::String("OK".into()), + InstructionResultValue::String("/__VOID__/".into()), + InstructionResultValue::String("__EXCEPTION__:text".into()), + InstructionResultValue::List(vec![InstructionResultValue::String("OK".into())]), + ]), + }; + let wire = response.to_slim_string(); assert_eq!( - InstructionResult { - id: id.clone(), - value: InstructionResultValue::List(vec![InstructionResultValue::String("Value 1".to_string()), InstructionResultValue::String("Value 2".to_string())]), - }, - InstructionResult::from_reader(&mut Cursor::new( - "000056:[000002:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000039:[000002:000007:Value 1:000007:Value 2:]:]" - ))? + response, + InstructionResult::from_reader(&mut Cursor::new(wire.as_bytes()))? ); + Ok(()) + } + + #[test] + fn top_level_response_markers_keep_their_protocol_meaning() -> Result<(), Box> { + let results = vec![ + InstructionResult::ok(Id::from("ok")), + InstructionResult::void(Id::from("void")), + InstructionResult::exception( + Id::from("exception"), + ExceptionMessage::new("message:<>".into()), + ), + ]; + let wire = results.to_slim_string(); assert_eq!( - InstructionResult { - id: id.clone(), - value: InstructionResultValue::Exception(ExceptionMessage::new("Message".into())), - }, - InstructionResult::from_reader(&mut Cursor::new( - "000073:[000002:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:0000021:__EXCEPTION__:Message:]" - ))? + results, + Vec::::from_reader(&mut Cursor::new(wire.as_bytes()))? ); - let exception = InstructionResult::from_reader(&mut Cursor::new( - "000100:[000002:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:0000048:__EXCEPTION__:Some Exception message:<>:]" - ))?; + Ok(()) + } + + #[test] + fn rejects_invalid_outer_frames_and_trailing_list_data() { + for wire in [ + b"0000:x".as_slice(), + b"00000x:x".as_slice(), + b"000006".as_slice(), + b"000001:\xff".as_slice(), + ] { + String::from_reader(&mut Cursor::new(wire)).expect_err("invalid frame must fail"); + } + + Vec::::from_reader(&mut Cursor::new("000010:[000000:]x")) + .expect_err("trailing data after a list must fail"); + } + + #[test] + fn accepts_seven_digit_outer_lengths() -> Result<(), Box> { + let value = "x".repeat(1_000_000); assert_eq!( - InstructionResult { - id: id.clone(), - value: InstructionResultValue::Exception(ExceptionMessage::new( - "Some Exception message:<>".into() - )) - }, - exception + value, + String::from_reader(&mut Cursor::new(value.to_slim_string().as_bytes()))? ); - let InstructionResultValue::Exception(message) = exception.value else { - return Err("Expected exception".into()); - }; - assert_eq!("Message", message.pretty_message()?); Ok(()) } } diff --git a/slim_protocol/src/slim_serialize.rs b/slim_protocol/src/slim_serialize.rs index c389f28..ca77f2d 100644 --- a/slim_protocol/src/slim_serialize.rs +++ b/slim_protocol/src/slim_serialize.rs @@ -1,13 +1,27 @@ -use crate::{InstructionResult, InstructionResultValue}; - -use super::Instruction; +use crate::{Instruction, InstructionResult, InstructionResultValue, SlimValue}; +/// A complete SliM message. Its leading length is measured in UTF-8 bytes. +/// +/// SliM uses a different unit for strings embedded in lists: UTF-16 code units. +/// Keeping the payload alongside the wire form makes that distinction explicit. #[derive(PartialEq, Eq, Debug)] -pub struct SlimString(String); +pub struct SlimString { + wire: Vec, + payload: String, +} impl SlimString { pub fn as_bytes(&self) -> &[u8] { - self.0.as_bytes() + &self.wire + } + + fn from_payload(payload: String) -> Self { + let wire = format!("{:06}:{}", payload.len(), payload).into_bytes(); + Self { wire, payload } + } + + fn nested(&self) -> String { + format!("{:06}:{}", utf16_len(&self.payload), self.payload) } } @@ -15,9 +29,9 @@ pub trait ToSlimString { fn to_slim_string(&self) -> SlimString; } -impl<'a> ToSlimString for &'a str { +impl ToSlimString for &str { fn to_slim_string(&self) -> SlimString { - SlimString(format!("{:0>6}:{}", self.len(), self)) + SlimString::from_payload((*self).into()) } } @@ -27,6 +41,15 @@ impl ToSlimString for String { } } +impl ToSlimString for SlimValue { + fn to_slim_string(&self) -> SlimString { + match self { + Self::String(value) => value.to_slim_string(), + Self::List(values) => values.to_slim_string(), + } + } +} + impl ToSlimString for Vec where T: ToSlimString, @@ -36,19 +59,18 @@ where } } -impl<'a, T> ToSlimString for &'a [T] +impl ToSlimString for &[T] where T: ToSlimString, { fn to_slim_string(&self) -> SlimString { - let mut result = String::from("["); - result += &format!("{:0>6}:", self.len()); - for value in self.iter() { - result += &value.to_slim_string().0; - result += ":"; + let mut payload = format!("[{:06}:", self.len()); + for value in *self { + payload.push_str(&value.to_slim_string().nested()); + payload.push(':'); } - result += "]"; - result.to_slim_string() + payload.push(']'); + SlimString::from_payload(payload) } } @@ -64,31 +86,28 @@ where impl ToSlimString for Instruction { fn to_slim_string(&self) -> SlimString { match self { + Self::Malformed { fields, .. } => fields.to_slim_string(), Self::Import { id, path } => [id.0.as_str(), "import", path.as_str()].to_slim_string(), Self::Make { id, instance, class, args, - } => [ - [id.0.as_str(), "make", instance, class].as_slice(), - into_vec_str(args).as_slice(), - ] - .concat() - .to_slim_string(), + } => instruction_parts( + [id.0.as_str(), "make", instance.as_str(), class.as_str()], + args, + ), Self::Call { id, instance, function, args, - } => [ - [id.0.as_str(), "call", instance, function].as_slice(), - into_vec_str(args).as_slice(), - ] - .concat() - .to_slim_string(), + } => instruction_parts( + [id.0.as_str(), "call", instance.as_str(), function.as_str()], + args, + ), Self::Assign { id, symbol, value } => { - [id.0.as_str(), "assign", symbol, value].to_slim_string() + [id.0.as_str(), "assign", symbol.as_str(), value.as_str()].to_slim_string() } Self::CallAndAssign { id, @@ -96,16 +115,26 @@ impl ToSlimString for Instruction { instance, function, args, - } => [ - [id.0.as_str(), "callAndAssign", symbol, instance, function].as_slice(), - into_vec_str(args).as_slice(), - ] - .concat() - .to_slim_string(), + } => instruction_parts( + [ + id.0.as_str(), + "callAndAssign", + symbol.as_str(), + instance.as_str(), + function.as_str(), + ], + args, + ), } } } +fn instruction_parts(head: [&str; N], args: &[String]) -> SlimString { + let mut values = head.into_iter().map(str::to_owned).collect::>(); + values.extend(args.iter().cloned()); + values.to_slim_string() +} + impl ToSlimString for Box { fn to_slim_string(&self) -> SlimString { self.as_ref().to_slim_string() @@ -114,32 +143,26 @@ impl ToSlimString for Box { impl ToSlimString for InstructionResult { fn to_slim_string(&self) -> SlimString { - [ - Box::new(self.id.0.clone()) as Box, - Box::new(self.value.clone()) as Box, - ] - .to_slim_string() + vec![self.id.0.clone(), self.value.to_slim_string().payload].to_slim_string() } } impl ToSlimString for InstructionResultValue { fn to_slim_string(&self) -> SlimString { match self { - InstructionResultValue::Ok => "OK".to_slim_string(), - InstructionResultValue::Void => "/__VOID__/".to_slim_string(), - InstructionResultValue::String(value) => value.as_str().to_slim_string(), - InstructionResultValue::Exception(message) => { - format!("__EXCEPTION__:{}", message.raw_message()) - .as_str() - .to_slim_string() + Self::Ok => "OK".to_slim_string(), + Self::Void => "/__VOID__/".to_slim_string(), + Self::String(value) => value.to_slim_string(), + Self::Exception(message) => { + format!("__EXCEPTION__:{}", message.raw_message()).to_slim_string() } - InstructionResultValue::List(list) => list.to_slim_string(), + Self::List(list) => list.to_slim_string(), } } } -fn into_vec_str(args: &[String]) -> Vec<&str> { - args.iter().map(|string| string.as_str()).collect() +fn utf16_len(value: &str) -> usize { + value.encode_utf16().count() } #[cfg(test)] @@ -148,111 +171,57 @@ mod test { use crate::Id; #[test] - fn test_empty_string() { - assert_eq!(SlimString("000000:".into()), "".to_slim_string()); - } - - #[test] - fn test_hello_world() { + fn outer_frames_use_utf8_bytes_and_nested_values_use_utf16_units() { + assert_eq!(b"000002:\xC3\xA9", "รฉ".to_slim_string().as_bytes()); assert_eq!( - SlimString("000011:hello world".into()), - "hello world".to_slim_string() + b"000019:[000001:000001:\xC3\xA9:]", + ["รฉ"].to_slim_string().as_bytes() ); - } - - #[test] - fn test_string() { assert_eq!( - SlimString("000011:hello world".into()), - String::from("hello world").to_slim_string() + b"000021:[000001:000002:\xF0\x9F\x98\x80:]", + ["๐Ÿ˜€"].to_slim_string().as_bytes() ); } #[test] - fn test_empty_array() { - let array: [String; 0] = []; + fn nested_lists_are_encoded_recursively() { + let nested = vec![vec!["one", "two"], vec!["๐Ÿ˜€"]]; assert_eq!( - SlimString("000009:[000000:]".into()), - array.to_slim_string() + b"000077:[000002:000031:[000002:000003:one:000003:two:]:000019:[000001:000002:\xF0\x9F\x98\x80:]:]", + nested.to_slim_string().as_bytes() ); } #[test] - fn test_array() { + fn recursive_wire_values_round_trip_through_their_encoding() { + let value = SlimValue::List(vec![ + SlimValue::String("รฉ".into()), + SlimValue::List(vec![SlimValue::String("๐Ÿ˜€".into())]), + ]); assert_eq!( - SlimString("000031:[000002:000003:one:000003:two:]".into()), - ["one", "two"].to_slim_string() + b"000048:[000002:000001:\xC3\xA9:000019:[000001:000002:\xF0\x9F\x98\x80:]:]", + value.to_slim_string().as_bytes() ); } #[test] - fn test_vec_and_slice() { - let vec = vec!["one", "two"]; - assert_eq!( - SlimString("000031:[000002:000003:one:000003:two:]".into()), - vec.as_slice().to_slim_string() - ); - assert_eq!( - SlimString("000031:[000002:000003:one:000003:two:]".into()), - vec.to_slim_string() - ); + fn lengths_grow_past_six_digits_without_truncation() { + let value = "x".repeat(1_000_000); + assert!(value.to_slim_string().as_bytes().starts_with(b"1000000:")); } #[test] - fn test_instructions() { + fn instruction_serialization_remains_available() { let id = Id::from("01HFM0NQM3ZS6BBX0ZH6VA6DJX"); + let instruction = Instruction::Call { + id, + instance: "fixture".into(), + function: "answer".into(), + args: vec!["๐Ÿ˜€".into()], + }; assert_eq!( - SlimString("000865:[000008:000074:[000003:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000006:import:000009:some_path:]:000084:[000004:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000004:make:000008:instance:000005:Class:]:000108:[000006:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000004:make:000008:instance:000005:Class:000004:Arg1:000004:Arg2:]:000087:[000004:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000004:call:000008:instance:000008:function:]:000111:[000006:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000004:call:000008:instance:000008:function:000004:Arg1:000004:Arg2:]:000084:[000004:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000006:assign:000006:Symbol:000005:value:]:000110:[000005:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000013:callAndAssign:000006:symbol:000008:instance:000008:function:]:000134:[000007:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000013:callAndAssign:000006:symbol:000008:instance:000008:function:000004:Arg1:000004:Arg2:]:]".into()), - [ - Instruction::Import { - id: id.clone(), - path: "some_path".into() - }, - Instruction::Make { - id: id.clone(), - instance: "instance".into(), - class: "Class".into(), - args: vec![] - }, - Instruction::Make { - id: id.clone(), - instance: "instance".into(), - class: "Class".into(), - args: vec!["Arg1".into(), "Arg2".into()] - }, - Instruction::Call { - id: id.clone(), - instance: "instance".into(), - function: "function".into(), - args: vec![] - }, - Instruction::Call { - id: id.clone(), - instance: "instance".into(), - function: "function".into(), - args: vec!["Arg1".into(), "Arg2".into()] - }, - Instruction::Assign { - id: id.clone(), - symbol: "Symbol".into(), - value: "value".into() - }, - Instruction::CallAndAssign { - id: id.clone(), - symbol: "symbol".into(), - instance: "instance".into(), - function: "function".into(), - args: vec![] - }, - Instruction::CallAndAssign { - id: id.clone(), - symbol: "symbol".into(), - instance: "instance".into(), - function: "function".into(), - args: vec!["Arg1".into(), "Arg2".into()] - } - ] - .to_slim_string() + b"000096:[000005:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000004:call:000007:fixture:000006:answer:000002:\xF0\x9F\x98\x80:]", + instruction.to_slim_string().as_bytes() ); } } diff --git a/temoc/src/processor/mod.rs b/temoc/src/processor/mod.rs index 1b44890..07bde23 100644 --- a/temoc/src/processor/mod.rs +++ b/temoc/src/processor/mod.rs @@ -130,7 +130,7 @@ fn print_fail_or_ok(show_snoozed: bool, failures: Vec<(String, Snooze)>) -> Resu for (failure, snooze) in failures.into_iter() { let should_snooze = snooze.should_snooze(); if should_snooze && show_snoozed { - let snooze_string = format!(" -- snoozed until {}", &snooze); + let snooze_string = format!(" -- snoozed until {}", snooze); println!( "{failure}{}", if should_snooze { &snooze_string } else { "" } diff --git a/temoc/src/processor/validate_result.rs b/temoc/src/processor/validate_result.rs index b174056..daa7334 100644 --- a/temoc/src/processor/validate_result.rs +++ b/temoc/src/processor/validate_result.rs @@ -20,9 +20,7 @@ pub fn validate_result( if expected_result.len() != result.len() { bail!("Number of instruction results `{}` does not matched the expected number of results `{}`", result.len(), expected_result.len()) } - for (result, (mut expected_result, snooze)) in - result.into_iter().zip(expected_result.into_iter()) - { + for (result, (mut expected_result, snooze)) in result.into_iter().zip(expected_result) { if expected_result.id != result.id { failures.push(( format!(