use base64::alphabet::STANDARD as BASE64_STANDARD_ALPHABET; use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig}; use base64::engine::{DecodePaddingMode, Engine as _}; const BASE64_DECODER: GeneralPurpose = GeneralPurpose::new( &BASE64_STANDARD_ALPHABET, GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent), ); use std::io::{Read, Seek}; use std::path::Path; pub const MAX_DECODED_BYTES: usize = 104_857_600; pub const MAX_DIMENSION: u32 = 10_000; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KittyAction { Transmit, TransmitAndDisplay, Display, Delete, Query, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KittyFormat { Rgb24, Rgba32, Png, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KittyMedium { Direct, File, TempFile, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KittyErrorCode { Einval, Enoent, Ebadf, Enodata, Enotsupported, } impl KittyErrorCode { pub fn as_str(&self) -> &'static str { match self { KittyErrorCode::Einval => "EINVAL", KittyErrorCode::Enoent => "ENOENT", KittyErrorCode::Ebadf => "EBADF", KittyErrorCode::Enodata => "ENODATA", KittyErrorCode::Enotsupported => "ENOTSUPPORTED", } } } #[derive(Debug, Clone, PartialEq)] pub struct KittyError { pub code: KittyErrorCode, pub message: String, pub image_id: Option, pub image_number: Option, pub placement_id: Option, pub quiet: u8, } #[derive(Debug, Clone, PartialEq)] pub struct DecodedImage { pub bytes: Vec, pub width: u32, pub height: u32, pub format: KittyFormat, } #[derive(Debug, Clone, PartialEq)] pub struct KittyCommand { pub action: KittyAction, pub format: KittyFormat, pub medium: KittyMedium, pub image_id: Option, pub image_number: Option, pub placement_id: Option, pub pixel_width: Option, pub pixel_height: Option, pub compressed: bool, pub more_chunks: bool, pub data_size: Option, pub data_offset: Option, pub source_x: u32, pub source_y: u32, pub source_w: u32, pub source_h: u32, pub columns: u32, pub rows: u32, pub cell_offset_x: u32, pub cell_offset_y: u32, pub z_index: i32, pub quiet: u8, pub suppress_cursor_movement: bool, pub delete_specifier: Option, pub image: Option, } impl Default for KittyCommand { fn default() -> Self { KittyCommand { action: KittyAction::Transmit, format: KittyFormat::Rgba32, medium: KittyMedium::Direct, image_id: None, image_number: None, placement_id: None, pixel_width: None, pixel_height: None, compressed: false, more_chunks: false, data_size: None, data_offset: None, source_x: 0, source_y: 0, source_w: 0, source_h: 0, columns: 0, rows: 0, cell_offset_x: 0, cell_offset_y: 0, z_index: 0, quiet: 0, suppress_cursor_movement: false, delete_specifier: None, image: None, } } } #[derive(Debug, Clone, Copy, Default)] struct EchoFields { image_id: Option, image_number: Option, placement_id: Option, quiet: u8, } impl EchoFields { fn from_command(command: &KittyCommand) -> Self { EchoFields { image_id: command.image_id, image_number: command.image_number, placement_id: command.placement_id, quiet: command.quiet, } } fn error(&self, code: KittyErrorCode, message: &str) -> KittyError { KittyError { code, message: message.to_owned(), image_id: self.image_id, image_number: self.image_number, placement_id: self.placement_id, quiet: self.quiet, } } } #[derive(Debug, Clone)] pub struct KittyCommandParser { pending: Option, } #[derive(Debug, Clone)] struct PendingUpload { command: KittyCommand, payload_b64: Vec, } impl KittyCommandParser { pub fn new() -> Self { KittyCommandParser { pending: None } } pub fn parse(&mut self, raw: &[u8]) -> Option> { let (control, payload) = split_control_payload(raw); if self.pending.is_some() { let pairs = match tokenize(control) { Ok(pairs) => pairs, Err(()) => { let pending = self.pending.take().unwrap(); let echo = EchoFields::from_command(&pending.command); return Some(Err( echo.error(KittyErrorCode::Einval, "malformed control data") )); }, }; let is_delete = pairs .iter() .any(|(key, value)| *key == b"a" && *value == b"d"); if is_delete { self.pending = None; } else { let mut more_chunks = false; for (key, value) in &pairs { if *key == b"m" { match parse_u32(value) { Some(0) => more_chunks = false, Some(1) => more_chunks = true, _ => { let pending = self.pending.take().unwrap(); let echo = EchoFields::from_command(&pending.command); return Some(Err( echo.error(KittyErrorCode::Einval, "malformed control data") )); }, } } } for (key, value) in &pairs { if *key == b"q" { if let Some(q @ 0..=2) = parse_u32(value) { self.pending.as_mut().unwrap().command.quiet = q as u8; } } } let pending = self.pending.as_mut().unwrap(); pending.payload_b64.extend_from_slice(payload); if more_chunks { return None; } let mut pending = self.pending.take().unwrap(); pending.command.more_chunks = false; return Some(run_payload_pipeline(pending.command, &pending.payload_b64)); } } let command = match parse_control_data(control) { Ok(command) => command, Err(err) => return Some(Err(err)), }; match command.action { KittyAction::Delete | KittyAction::Display => Some(Ok(command)), KittyAction::Transmit | KittyAction::TransmitAndDisplay | KittyAction::Query => { if command.more_chunks { self.pending = Some(PendingUpload { command, payload_b64: payload.to_vec(), }); None } else { Some(run_payload_pipeline(command, payload)) } }, } } pub fn abort_pending(&mut self) { self.pending = None; } pub fn has_pending(&self) -> bool { self.pending.is_some() } } fn split_control_payload(raw: &[u8]) -> (&[u8], &[u8]) { match raw.iter().position(|b| *b == b';') { Some(pos) => (&raw[..pos], &raw[pos + 1..]), None => (raw, &raw[raw.len()..]), } } fn tokenize(control: &[u8]) -> Result, ()> { let mut pairs = Vec::new(); if control.is_empty() { return Ok(pairs); } for segment in control.split(|b| *b == b',') { if segment.is_empty() { continue; } let eq_pos = segment.iter().position(|b| *b == b'=').ok_or(())?; if eq_pos == 0 { return Err(()); } pairs.push((&segment[..eq_pos], &segment[eq_pos + 1..])); } Ok(pairs) } fn parse_u32(value: &[u8]) -> Option { std::str::from_utf8(value).ok()?.parse::().ok() } fn parse_i32(value: &[u8]) -> Option { std::str::from_utf8(value).ok()?.parse::().ok() } fn scan_echo_fields(control: &[u8]) -> EchoFields { let mut echo = EchoFields::default(); for segment in control.split(|b| *b == b',') { if let Some(eq_pos) = segment.iter().position(|b| *b == b'=') { let key = &segment[..eq_pos]; let value = &segment[eq_pos + 1..]; match key { b"q" => { if let Some(q @ 0..=2) = parse_u32(value) { echo.quiet = q as u8; } }, b"i" => echo.image_id = parse_u32(value).or(echo.image_id), b"I" => echo.image_number = parse_u32(value).or(echo.image_number), b"p" => echo.placement_id = parse_u32(value).or(echo.placement_id), _ => {}, } } } echo } pub fn parse_control_data(control: &[u8]) -> Result { let echo = scan_echo_fields(control); let pairs = tokenize(control) .map_err(|_| echo.error(KittyErrorCode::Einval, "malformed control data"))?; let mut command = KittyCommand::default(); for (key, value) in pairs { match key { b"a" => { if value.len() != 1 { return Err(echo.error(KittyErrorCode::Einval, "invalid action")); } command.action = match value[0] { b't' => KittyAction::Transmit, b'T' => KittyAction::TransmitAndDisplay, b'p' => KittyAction::Display, b'd' => KittyAction::Delete, b'q' => KittyAction::Query, b'f' | b'a' | b'c' => { return Err( echo.error(KittyErrorCode::Enotsupported, "animation is not supported") ); }, _ => return Err(echo.error(KittyErrorCode::Einval, "invalid action")), }; }, b"f" => { command.format = match parse_u32(value) { Some(24) => KittyFormat::Rgb24, Some(32) => KittyFormat::Rgba32, Some(100) => KittyFormat::Png, _ => return Err(echo.error(KittyErrorCode::Einval, "invalid format")), }; }, b"t" => { if value.len() != 1 { return Err(echo.error(KittyErrorCode::Einval, "invalid transmission medium")); } command.medium = match value[0] { b'd' => KittyMedium::Direct, b'f' => KittyMedium::File, b't' => KittyMedium::TempFile, b's' => { return Err(echo.error( KittyErrorCode::Enotsupported, "shared memory transfer is not supported", )); }, _ => { return Err( echo.error(KittyErrorCode::Einval, "invalid transmission medium") ); }, }; }, b"s" => { command.pixel_width = Some(parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 's'") })?); }, b"v" => { command.pixel_height = Some(parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'v'") })?); }, b"o" => { if value != b"z" { return Err(echo.error(KittyErrorCode::Einval, "invalid compression")); } command.compressed = true; }, b"m" => { command.more_chunks = match parse_u32(value) { Some(0) => false, Some(1) => true, _ => { return Err(echo.error(KittyErrorCode::Einval, "invalid value for key 'm'")); }, }; }, b"S" => { command.data_size = Some(parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'S'") })?); }, b"O" => { command.data_offset = Some(parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'O'") })?); }, b"i" => { command.image_id = Some(parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'i'") })?); }, b"I" => { command.image_number = Some(parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'I'") })?); }, b"p" => { command.placement_id = Some(parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'p'") })?); }, b"x" => { command.source_x = parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'x'") })?; }, b"y" => { command.source_y = parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'y'") })?; }, b"w" => { command.source_w = parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'w'") })?; }, b"h" => { command.source_h = parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'h'") })?; }, b"c" => { command.columns = parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'c'") })?; }, b"r" => { command.rows = parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'r'") })?; }, b"X" => { command.cell_offset_x = parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'X'") })?; }, b"Y" => { command.cell_offset_y = parse_u32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'Y'") })?; }, b"z" => { command.z_index = parse_i32(value).ok_or_else(|| { echo.error(KittyErrorCode::Einval, "invalid value for key 'z'") })?; }, b"q" => { command.quiet = match parse_u32(value) { Some(q @ 0..=2) => q as u8, _ => { return Err(echo.error(KittyErrorCode::Einval, "invalid value for key 'q'")); }, }; }, b"C" => { command.suppress_cursor_movement = match parse_u32(value) { Some(0) => false, Some(1) => true, _ => { return Err(echo.error(KittyErrorCode::Einval, "invalid value for key 'C'")); }, }; }, b"d" => { if value.len() != 1 || !b"aAiInNcCpPqQxXyYzZrR".contains(&value[0]) { return Err(echo.error(KittyErrorCode::Einval, "invalid delete specifier")); } command.delete_specifier = Some(value[0] as char); }, b"U" => match parse_u32(value) { Some(0) => {}, Some(_) => { return Err(echo.error( KittyErrorCode::Enotsupported, "unicode placeholders are not supported", )); }, None => { return Err(echo.error(KittyErrorCode::Einval, "invalid value for key 'U'")); }, }, b"N" | b"P" | b"Q" | b"H" | b"V" => {}, _ => {}, } } if command.image_id.unwrap_or(0) != 0 && command.image_number.is_some() { return Err(echo.error( KittyErrorCode::Einval, "must not specify both image id and image number", )); } Ok(command) } fn should_delete_temp_file(path: &Path) -> bool { path.to_string_lossy().contains("tty-graphics-protocol") || path.starts_with(std::env::temp_dir()) || path.starts_with("/tmp") || path.starts_with("/dev/shm") } fn read_file_range( path: &Path, file_len: u64, command: &KittyCommand, echo: &EchoFields, ) -> Result, KittyError> { let start = command.data_offset.unwrap_or(0) as u64; if start > file_len { return Err(echo.error(KittyErrorCode::Ebadf, "could not read file")); } let available = file_len - start; let length = match command.data_size { Some(size) if size > 0 => std::cmp::min(size as u64, available), _ => available, }; if length > MAX_DECODED_BYTES as u64 { return Err(echo.error(KittyErrorCode::Einval, "image too large")); } let read = |path: &Path| -> std::io::Result> { let mut file = std::fs::File::open(path)?; if start > 0 { file.seek(std::io::SeekFrom::Start(start))?; } let mut bytes = Vec::new(); file.take(length).read_to_end(&mut bytes)?; Ok(bytes) }; read(path).map_err(|_| echo.error(KittyErrorCode::Ebadf, "could not read file")) } fn run_payload_pipeline( mut command: KittyCommand, payload_b64: &[u8], ) -> Result { let echo = EchoFields::from_command(&command); let trimmed_len = payload_b64 .iter() .rposition(|b| *b != b'=') .map(|pos| pos + 1) .unwrap_or(0); let decoded = BASE64_DECODER .decode(&payload_b64[..trimmed_len]) .map_err(|_| echo.error(KittyErrorCode::Einval, "invalid base64 payload"))?; let data = match command.medium { KittyMedium::Direct => decoded, KittyMedium::File | KittyMedium::TempFile => { let path_string = String::from_utf8(decoded) .map_err(|_| echo.error(KittyErrorCode::Ebadf, "invalid file path"))?; let path = std::path::PathBuf::from(path_string); let metadata = std::fs::metadata(&path) .map_err(|_| echo.error(KittyErrorCode::Ebadf, "could not read file"))?; if !metadata.is_file() { return Err(echo.error(KittyErrorCode::Ebadf, "could not read file")); } let read_result = read_file_range(&path, metadata.len(), &command, &echo); if command.medium == KittyMedium::TempFile && should_delete_temp_file(&path) { let _ = std::fs::remove_file(&path); } read_result? }, }; let data = if command.compressed { let mut inflated = Vec::new(); flate2::read::ZlibDecoder::new(&data[..]) .take((MAX_DECODED_BYTES + 1) as u64) .read_to_end(&mut inflated) .map_err(|_| echo.error(KittyErrorCode::Einval, "could not inflate payload"))?; if inflated.len() > MAX_DECODED_BYTES { return Err(echo.error(KittyErrorCode::Einval, "image too large")); } inflated } else { data }; let image = match command.format { KittyFormat::Rgb24 | KittyFormat::Rgba32 => { let bpp: u64 = if command.format == KittyFormat::Rgb24 { 3 } else { 4 }; let width = command.pixel_width.unwrap_or(0); let height = command.pixel_height.unwrap_or(0); if width == 0 || height == 0 { return Err(echo.error( KittyErrorCode::Einval, "dimensions missing for raw image data", )); } if width > MAX_DIMENSION || height > MAX_DIMENSION { return Err(echo.error(KittyErrorCode::Einval, "image dimensions too large")); } let expected = width as u64 * height as u64 * bpp; if expected > MAX_DECODED_BYTES as u64 { return Err(echo.error(KittyErrorCode::Einval, "image too large")); } if (data.len() as u64) < expected { return Err(echo.error( KittyErrorCode::Enodata, "insufficient image data for dimensions", )); } let mut data = data; data.truncate(expected as usize); DecodedImage { bytes: data, width, height, format: command.format, } }, KittyFormat::Png => decode_png(&data, &echo)?, }; command.image = Some(image); Ok(command) } fn decode_png(data: &[u8], echo: &EchoFields) -> Result { let limits = png::Limits { bytes: MAX_DECODED_BYTES, }; let mut decoder = png::Decoder::new_with_limits(std::io::Cursor::new(data), limits); decoder.set_transformations(png::Transformations::normalize_to_color8()); let mut reader = decoder .read_info() .map_err(|_| echo.error(KittyErrorCode::Einval, "invalid png data"))?; let width = reader.info().width; let height = reader.info().height; if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION { return Err(echo.error(KittyErrorCode::Einval, "image dimensions too large")); } if width as u64 * height as u64 * 4 > MAX_DECODED_BYTES as u64 { return Err(echo.error(KittyErrorCode::Einval, "image too large")); } let mut buf = vec![0; reader.output_buffer_size()]; let out = reader .next_frame(&mut buf) .map_err(|_| echo.error(KittyErrorCode::Einval, "invalid png data"))?; buf.truncate(out.buffer_size()); let rgba = match out.color_type { png::ColorType::Rgba => buf, png::ColorType::Rgb => { let mut rgba = Vec::with_capacity(buf.len() / 3 * 4); for pixel in buf.chunks_exact(3) { rgba.extend_from_slice(pixel); rgba.push(255); } rgba }, png::ColorType::Grayscale => { let mut rgba = Vec::with_capacity(buf.len() * 4); for g in buf { rgba.extend_from_slice(&[g, g, g, 255]); } rgba }, png::ColorType::GrayscaleAlpha => { let mut rgba = Vec::with_capacity(buf.len() * 2); for pixel in buf.chunks_exact(2) { rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]]); } rgba }, _ => return Err(echo.error(KittyErrorCode::Einval, "invalid png data")), }; if rgba.len() > MAX_DECODED_BYTES { return Err(echo.error(KittyErrorCode::Einval, "image too large")); } Ok(DecodedImage { bytes: rgba, width: out.width, height: out.height, format: KittyFormat::Rgba32, }) } #[cfg(test)] #[path = "./unit/parser_tests.rs"] mod parser_tests;