use crate::simd_base64;
use crate::time::{Duration, Instant};
use rio_graphics::{ColorType, GraphicData, GraphicId, ResizeCommand, ResizeParameter};
use smallvec::SmallVec;
use std::collections::HashMap;
use tracing::debug;
const MAX_DIMENSION: u32 = 10_000;
const MAX_SIZE: usize = 400 * 1024 * 1024;
const CHUNK_STALE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Default)]
pub struct KittyGraphicsState {
incomplete_images: HashMap<u32, KittyGraphicsCommand>,
current_transmission_key: u32,
next_auto_image_id: u32,
}
impl KittyGraphicsState {
fn allocate_image_id(&mut self) -> u32 {
if self.next_auto_image_id < 0x80000000 {
self.next_auto_image_id = 0x80000000;
}
let id = self.next_auto_image_id;
self.next_auto_image_id =
self.next_auto_image_id.checked_add(1).unwrap_or(0x80000000);
id
}
}
#[derive(Debug)]
pub struct KittyGraphicsResponse {
pub graphic_data: Option<GraphicData>,
pub placement_request: Option<PlacementRequest>,
pub delete_request: Option<DeleteRequest>,
pub response: Option<String>,
pub error_response: Option<String>,
pub incomplete: bool,
}
impl KittyGraphicsResponse {
fn pending_chunk() -> Self {
Self {
graphic_data: None,
placement_request: None,
delete_request: None,
response: None,
error_response: None,
incomplete: true,
}
}
}
#[derive(Debug)]
pub struct PlacementRequest {
pub image_id: u32,
pub placement_id: u32,
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub columns: u32,
pub rows: u32,
pub z_index: i32,
pub virtual_placement: bool,
pub unicode_placeholder: u32,
pub cursor_movement: u8, pub cell_x_offset: u32,
pub cell_y_offset: u32,
}
#[derive(Debug)]
pub struct DeleteRequest {
pub action: u8,
pub image_id: u32,
pub image_number: u32,
pub placement_id: u32,
pub x: u32,
pub y: u32,
pub z_index: i32,
pub delete_data: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Action {
Transmit,
TransmitAndDisplay,
Query,
Put,
Delete,
Frame,
Animate,
Compose,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Format {
Gray, GrayAlpha, Rgb24, Rgba32, Png,
}
impl Format {
fn bytes_per_pixel(self) -> usize {
match self {
Format::Gray => 1,
Format::GrayAlpha => 2,
Format::Rgb24 => 3,
Format::Rgba32 => 4,
Format::Png => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum TransmissionMedium {
Direct,
File,
TempFile,
SharedMemory,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Compression {
None,
Zlib,
}
#[derive(Debug, Clone)]
pub struct KittyGraphicsCommand {
action: Action,
quiet: u8,
implicit_id: bool,
format: Format,
medium: TransmissionMedium,
width: u32,
height: u32,
size: u32,
offset: u32,
image_id: u32,
image_number: u32,
placement_id: u32,
compression: Compression,
more: bool,
source_x: u32,
source_y: u32,
source_width: u32,
source_height: u32,
cell_x_offset: u32,
cell_y_offset: u32,
columns: u32,
rows: u32,
cursor_movement: u8,
virtual_placement: bool,
z_index: i32,
parent_id: u32,
parent_placement_id: u32,
relative_x: i32,
relative_y: i32,
frame_number: u32,
base_frame: u32,
frame_gap: i32,
composition_mode: u8,
background_color: u32,
animation_state: u8,
loop_count: u32,
current_frame: u32,
delete_action: u8,
unicode_placeholder: u32,
payload: SmallVec<[u8; 64]>,
last_touched: Instant,
}
impl Default for KittyGraphicsCommand {
fn default() -> Self {
Self {
action: Action::Transmit,
quiet: 0,
implicit_id: false,
format: Format::Rgba32,
medium: TransmissionMedium::Direct,
width: 0,
height: 0,
size: 0,
offset: 0,
image_id: 0,
image_number: 0,
placement_id: 0,
compression: Compression::None,
more: false,
source_x: 0,
source_y: 0,
source_width: 0,
source_height: 0,
cell_x_offset: 0,
cell_y_offset: 0,
columns: 0,
rows: 0,
cursor_movement: 0,
virtual_placement: false,
z_index: 0,
parent_id: 0,
parent_placement_id: 0,
relative_x: 0,
relative_y: 0,
frame_number: 0,
base_frame: 0,
frame_gap: 0,
composition_mode: 0,
background_color: 0,
animation_state: 0,
loop_count: 0,
current_frame: 0,
delete_action: b'a',
unicode_placeholder: 0,
payload: SmallVec::new(),
last_touched: Instant::now(),
}
}
}
fn encode_response(
image_id: u32,
image_number: u32,
placement_id: u32,
message: &str,
) -> Option<String> {
if image_id == 0 && image_number == 0 {
return None;
}
let mut keys = String::new();
if image_id > 0 {
keys.push_str(&format!("i={image_id}"));
}
if image_number > 0 {
if !keys.is_empty() {
keys.push(',');
}
keys.push_str(&format!("I={image_number}"));
}
if placement_id > 0 {
if !keys.is_empty() {
keys.push(',');
}
keys.push_str(&format!("p={placement_id}"));
}
Some(format!("\x1b_G{keys};{message}\x1b\\"))
}
fn encode_response_quiet(
image_id: u32,
image_number: u32,
placement_id: u32,
message: &str,
quiet: u8,
is_error: bool,
) -> Option<String> {
match quiet {
0 => encode_response(image_id, image_number, placement_id, message),
1 if is_error => encode_response(image_id, image_number, placement_id, message),
_ => None,
}
}
pub fn parse(
params: &[&[u8]],
state: &mut KittyGraphicsState,
) -> Option<KittyGraphicsResponse> {
let Some(&b"G") = params.first() else {
debug!("Kitty graphics parse failed: first param is not 'G'");
return None;
};
debug!(
"Kitty graphics parse: starting with {} params",
params.len()
);
for (i, param) in params.iter().enumerate() {
debug!(
" param[{}] length={}, preview={:?}",
i,
param.len(),
std::str::from_utf8(¶m[..param.len().min(50)])
.unwrap_or("(invalid utf8)")
);
}
let mut cmd = KittyGraphicsCommand::default();
if let Some(control) = params.get(1) {
if !control.is_empty() {
let control_data = std::str::from_utf8(control).ok()?;
parse_control_data(&mut cmd, control_data);
}
}
if let Some(payload) = params.get(2) {
if !payload.is_empty() {
let decoded = decode_payload_base64(payload)?;
cmd.payload = SmallVec::from_vec(decoded);
}
}
if cmd.image_id > 0 && cmd.image_number > 0 {
return Some(KittyGraphicsResponse {
graphic_data: None,
placement_request: None,
delete_request: None,
response: encode_response_quiet(
cmd.image_id,
cmd.image_number,
cmd.placement_id,
"EINVAL: image ID and number are mutually exclusive",
cmd.quiet,
true,
),
error_response: None,
incomplete: false,
});
}
if cmd.action == Action::Query && cmd.image_id == 0 {
return Some(KittyGraphicsResponse {
graphic_data: None,
placement_request: None,
delete_request: None,
response: encode_response_quiet(
cmd.image_id,
cmd.image_number,
cmd.placement_id,
"EINVAL: image ID required",
cmd.quiet,
true,
),
error_response: None,
incomplete: false,
});
}
let image_key = if cmd.image_id > 0 || cmd.image_number > 0 {
if cmd.image_id > 0 {
cmd.image_id
} else {
cmd.image_number
}
} else if state.current_transmission_key != 0 {
state.current_transmission_key
} else {
let key = state.allocate_image_id();
cmd.image_id = key;
cmd.implicit_id = true;
key
};
evict_stale_chunks(state);
if cmd.more {
state.current_transmission_key = image_key;
use std::collections::hash_map::Entry;
match state.incomplete_images.entry(image_key) {
Entry::Vacant(e) => {
let expected_size = cmd.size as usize;
if expected_size > 0 && cmd.payload.capacity() < expected_size {
cmd.payload
.reserve(expected_size.saturating_sub(cmd.payload.len()));
debug!(
"First chunk for image key {}: {} bytes, reserved {} bytes total",
image_key,
cmd.payload.len(),
expected_size
);
} else {
debug!(
"First chunk for image key {}: {} bytes",
image_key,
cmd.payload.len()
);
}
cmd.last_touched = Instant::now();
e.insert(cmd);
}
Entry::Occupied(mut e) => {
let stored_cmd = e.get_mut();
if stored_cmd.payload.len().saturating_add(cmd.payload.len()) > MAX_SIZE {
debug!(
"Dropping chunked upload {}: would exceed MAX_SIZE ({})",
image_key, MAX_SIZE
);
e.remove();
state.current_transmission_key = 0;
return None;
}
stored_cmd.payload.extend_from_slice(&cmd.payload);
stored_cmd.last_touched = Instant::now();
debug!(
"Appended chunk for image key {}: {} bytes accumulated",
image_key,
stored_cmd.payload.len()
);
}
}
return Some(KittyGraphicsResponse::pending_chunk());
} else {
if let Some(mut stored_cmd) = state.incomplete_images.remove(&image_key) {
if stored_cmd.payload.len().saturating_add(cmd.payload.len()) > MAX_SIZE {
debug!(
"Dropping final chunk {}: would exceed MAX_SIZE ({})",
image_key, MAX_SIZE
);
state.current_transmission_key = 0;
return None;
}
stored_cmd.payload.extend_from_slice(&cmd.payload);
cmd = stored_cmd; debug!(
"Retrieved accumulated image key {}: total {} bytes",
image_key,
cmd.payload.len()
);
state.current_transmission_key = 0;
}
}
debug!("Kitty graphics action: {:?}, format={:?}, width={}, height={}, image_id={}, payload_len={}",
cmd.action, cmd.format, cmd.width, cmd.height, cmd.image_id, cmd.payload.len());
match cmd.action {
Action::Transmit | Action::TransmitAndDisplay => {
debug!("Creating graphic data: format={:?}, medium={:?}, compression={:?}, width={}, height={}, payload_len={}",
cmd.format, cmd.medium, cmd.compression, cmd.width, cmd.height, cmd.payload.len());
let graphic_data = match create_graphic_data(&cmd) {
Ok(g) => g,
Err(err) => {
return Some(KittyGraphicsResponse {
graphic_data: None,
placement_request: None,
delete_request: None,
response: if cmd.implicit_id {
None
} else {
encode_response_quiet(
cmd.image_id,
cmd.image_number,
cmd.placement_id,
err.message(),
cmd.quiet,
true,
)
},
error_response: None,
incomplete: false,
});
}
};
debug!(
"Graphic data created successfully: {}x{}",
graphic_data.width, graphic_data.height
);
let response = if cmd.implicit_id {
None
} else {
encode_response_quiet(
graphic_data.id.get() as u32,
cmd.image_number,
cmd.placement_id,
"OK",
cmd.quiet,
false,
)
};
let placement_request = if cmd.action == Action::TransmitAndDisplay {
Some(PlacementRequest {
image_id: cmd.image_id,
placement_id: cmd.placement_id,
x: cmd.source_x,
y: cmd.source_y,
width: cmd.source_width,
height: cmd.source_height,
columns: cmd.columns,
rows: cmd.rows,
z_index: cmd.z_index,
virtual_placement: cmd.virtual_placement,
unicode_placeholder: cmd.unicode_placeholder,
cursor_movement: cmd.cursor_movement,
cell_x_offset: cmd.cell_x_offset,
cell_y_offset: cmd.cell_y_offset,
})
} else {
None
};
Some(KittyGraphicsResponse {
graphic_data: Some(graphic_data),
placement_request,
delete_request: None,
response,
error_response: None,
incomplete: false,
})
}
Action::Put => {
let placement = PlacementRequest {
image_id: cmd.image_id,
placement_id: cmd.placement_id,
x: cmd.source_x,
y: cmd.source_y,
width: cmd.source_width,
height: cmd.source_height,
columns: cmd.columns,
rows: cmd.rows,
z_index: cmd.z_index,
virtual_placement: cmd.virtual_placement,
unicode_placeholder: cmd.unicode_placeholder,
cursor_movement: cmd.cursor_movement,
cell_x_offset: cmd.cell_x_offset,
cell_y_offset: cmd.cell_y_offset,
};
let response = if cmd.implicit_id {
None
} else {
encode_response_quiet(
cmd.image_id,
cmd.image_number,
cmd.placement_id,
"OK",
cmd.quiet,
false,
)
};
let error_response = encode_response_quiet(
cmd.image_id,
cmd.image_number,
cmd.placement_id,
"ENOENT: image not found",
cmd.quiet,
true,
);
Some(KittyGraphicsResponse {
graphic_data: None,
placement_request: Some(placement),
delete_request: None,
response,
error_response,
incomplete: false,
})
}
Action::Delete => {
let delete_data = cmd.delete_action.is_ascii_uppercase();
let delete = DeleteRequest {
action: cmd.delete_action.to_ascii_lowercase(),
image_id: cmd.image_id,
image_number: cmd.image_number,
placement_id: cmd.placement_id,
x: cmd.source_x,
y: cmd.source_y,
z_index: cmd.z_index,
delete_data,
};
Some(KittyGraphicsResponse {
graphic_data: None,
placement_request: None,
delete_request: Some(delete),
response: None,
error_response: None,
incomplete: false,
})
}
Action::Query => {
let response = match create_graphic_data(&cmd) {
Ok(_) => encode_response_quiet(
cmd.image_id,
cmd.image_number,
cmd.placement_id,
"OK",
cmd.quiet,
false,
),
Err(err) => encode_response_quiet(
cmd.image_id,
cmd.image_number,
cmd.placement_id,
err.message(),
cmd.quiet,
true,
),
};
Some(KittyGraphicsResponse {
graphic_data: None,
placement_request: None,
delete_request: None,
response,
error_response: None,
incomplete: false,
})
}
Action::Frame | Action::Animate | Action::Compose => {
let response = if cmd.implicit_id {
None
} else {
encode_response_quiet(
cmd.image_id,
cmd.image_number,
cmd.placement_id,
"EINVAL:unsupported action",
cmd.quiet,
true,
)
.or_else(|| match cmd.quiet {
2 => None,
_ => Some("\x1b_G;EINVAL:unsupported action\x1b\\".to_string()),
})
};
Some(KittyGraphicsResponse {
graphic_data: None,
placement_request: None,
delete_request: None,
response,
error_response: None,
incomplete: false,
})
}
}
}
fn parse_control_data(cmd: &mut KittyGraphicsCommand, control_data: &str) {
for pair in control_data.split(',') {
if let Some((key, value)) = pair.split_once('=') {
if key == "a" {
cmd.action = parse_action(value);
break;
}
}
}
for pair in control_data.split(',') {
if let Some((key, value)) = pair.split_once('=') {
match key {
"a" => {}
"q" => cmd.quiet = value.parse().unwrap_or(0),
"f" => cmd.format = parse_format(value),
"t" => cmd.medium = parse_transmission_medium(value),
"s" => match cmd.action {
Action::Animate => cmd.animation_state = value.parse().unwrap_or(0),
_ => cmd.width = value.parse().unwrap_or(0),
},
"v" => match cmd.action {
Action::Animate => cmd.loop_count = value.parse().unwrap_or(0),
_ => cmd.height = value.parse().unwrap_or(0),
},
"S" => cmd.size = value.parse().unwrap_or(0),
"O" => cmd.offset = value.parse().unwrap_or(0),
"i" => cmd.image_id = value.parse().unwrap_or(0),
"I" => cmd.image_number = value.parse().unwrap_or(0),
"p" => cmd.placement_id = value.parse().unwrap_or(0),
"o" => cmd.compression = parse_compression(value),
"m" => cmd.more = value == "1",
"x" => match cmd.action {
Action::Delete => cmd.source_x = value.parse().unwrap_or(0),
_ => cmd.source_x = value.parse().unwrap_or(0),
},
"y" => match cmd.action {
Action::Delete => cmd.source_y = value.parse().unwrap_or(0),
_ => cmd.source_y = value.parse().unwrap_or(0),
},
"w" => cmd.source_width = value.parse().unwrap_or(0),
"h" => cmd.source_height = value.parse().unwrap_or(0),
"X" => match cmd.action {
Action::Frame | Action::Compose => {
cmd.composition_mode = value.parse().unwrap_or(0)
}
_ => cmd.cell_x_offset = value.parse().unwrap_or(0),
},
"Y" => match cmd.action {
Action::Frame => cmd.background_color = value.parse().unwrap_or(0),
_ => cmd.cell_y_offset = value.parse().unwrap_or(0),
},
"c" => match cmd.action {
Action::Frame | Action::Compose => {
cmd.base_frame = value.parse().unwrap_or(0)
}
Action::Animate => cmd.current_frame = value.parse().unwrap_or(0),
_ => cmd.columns = value.parse().unwrap_or(0),
},
"r" => match cmd.action {
Action::Frame | Action::Compose | Action::Animate => {
cmd.frame_number = value.parse().unwrap_or(0)
}
_ => cmd.rows = value.parse().unwrap_or(0),
},
"z" => match cmd.action {
Action::Frame | Action::Animate => {
cmd.frame_gap = value.parse().unwrap_or(0)
}
_ => cmd.z_index = value.parse().unwrap_or(0),
},
"C" => cmd.cursor_movement = value.parse().unwrap_or(0),
"U" => cmd.virtual_placement = value == "1",
"P" => cmd.parent_id = value.parse().unwrap_or(0),
"Q" => cmd.parent_placement_id = value.parse().unwrap_or(0),
"H" => cmd.relative_x = value.parse().unwrap_or(0),
"V" => cmd.relative_y = value.parse().unwrap_or(0),
"d" => {
cmd.delete_action = value.as_bytes().first().copied().unwrap_or(b'a')
}
"u" => cmd.unicode_placeholder = value.parse().unwrap_or(0),
_ => {} }
}
}
}
fn parse_action(value: &str) -> Action {
match value {
"t" => Action::Transmit,
"T" => Action::TransmitAndDisplay,
"q" => Action::Query,
"p" => Action::Put,
"d" => Action::Delete,
"f" => Action::Frame,
"a" => Action::Animate,
"c" => Action::Compose,
_ => Action::Transmit,
}
}
fn parse_format(value: &str) -> Format {
match value {
"8" => Format::Gray,
"16" => Format::GrayAlpha,
"24" => Format::Rgb24,
"32" => Format::Rgba32,
"100" => Format::Png,
_ => Format::Rgba32,
}
}
fn parse_transmission_medium(value: &str) -> TransmissionMedium {
match value {
"d" => TransmissionMedium::Direct,
"f" => TransmissionMedium::File,
"t" => TransmissionMedium::TempFile,
"s" => TransmissionMedium::SharedMemory,
_ => TransmissionMedium::Direct,
}
}
fn parse_compression(value: &str) -> Compression {
match value {
"z" => Compression::Zlib,
_ => Compression::None,
}
}
fn evict_stale_chunks(state: &mut KittyGraphicsState) {
if state.incomplete_images.is_empty() {
return;
}
let now = Instant::now();
let before = state.incomplete_images.len();
state
.incomplete_images
.retain(|_, cmd| now.duration_since(cmd.last_touched) < CHUNK_STALE_TIMEOUT);
let after = state.incomplete_images.len();
if after < before {
debug!(
"Evicted {} stale chunked uploads (>{}s idle)",
before - after,
CHUNK_STALE_TIMEOUT.as_secs()
);
if !state
.incomplete_images
.contains_key(&state.current_transmission_key)
{
state.current_transmission_key = 0;
}
}
}
fn decode_payload_base64(payload: &[u8]) -> Option<Vec<u8>> {
if payload.is_empty() {
return Some(Vec::new());
}
if let Some(data) = simd_base64::decode(payload) {
return Some(data);
}
if let Some(data) = simd_base64::decode_no_pad(payload) {
return Some(data);
}
debug!("Base64 payload decode failed");
None
}
#[derive(Debug)]
#[allow(dead_code)] enum GraphicError {
DimensionsTooLarge,
DimensionsRequired,
TooLarge,
UnsupportedFormat,
UnsupportedMedium,
InvalidData,
DecompressionFailed,
FileNotFound,
}
impl GraphicError {
fn message(&self) -> &'static str {
match self {
GraphicError::DimensionsTooLarge => "EINVAL: dimensions too large",
GraphicError::DimensionsRequired => "EINVAL: dimensions required",
GraphicError::TooLarge => "E2BIG: image too large",
GraphicError::UnsupportedFormat => "EINVAL: unsupported format",
GraphicError::UnsupportedMedium => "EINVAL: unsupported medium",
GraphicError::InvalidData => "EINVAL: invalid data",
GraphicError::DecompressionFailed => "EINVAL: decompression failed",
GraphicError::FileNotFound => "ENOENT: file not found",
}
}
}
#[cfg(any(test, windows, all(unix, not(target_os = "android"))))]
fn data_range(offset: u32, size: usize, total: usize) -> Option<std::ops::Range<usize>> {
let start = offset as usize;
let available = total.checked_sub(start)?;
let len = if size > 0 { size } else { available };
if len == 0 || len > available {
return None;
}
Some(start..start + len)
}
#[cfg(any(windows, all(unix, not(target_os = "android"))))]
fn shm_range(
cmd: &KittyGraphicsCommand,
shm_size: usize,
) -> Result<std::ops::Range<usize>, GraphicError> {
let size = requested_size(cmd)?;
let Some(range) = data_range(cmd.offset, size, shm_size) else {
debug!(
"Invalid range offset={} size={} for shared memory size {}",
cmd.offset, size, shm_size
);
return Err(GraphicError::InvalidData);
};
if range.len() > MAX_SIZE {
return Err(GraphicError::TooLarge);
}
Ok(range)
}
#[cfg(any(test, all(unix, not(target_os = "android"))))]
fn valid_shm_name(name: &str) -> bool {
const NAME_MAX: usize = 255;
!name.is_empty() && name != "/" && name.len() <= NAME_MAX
}
#[cfg(all(unix, not(target_os = "android")))]
struct ShmGuard<'a> {
fd: libc::c_int,
name: &'a std::ffi::CStr,
}
#[cfg(all(unix, not(target_os = "android")))]
impl Drop for ShmGuard<'_> {
fn drop(&mut self) {
unsafe {
libc::close(self.fd);
libc::shm_unlink(self.name.as_ptr());
}
}
}
#[cfg(all(unix, not(target_os = "android")))]
struct MapGuard {
ptr: *mut libc::c_void,
len: usize,
}
#[cfg(all(unix, not(target_os = "android")))]
impl Drop for MapGuard {
fn drop(&mut self) {
unsafe {
libc::munmap(self.ptr, self.len);
}
}
}
fn raw_image_size(cmd: &KittyGraphicsCommand) -> Option<usize> {
(cmd.width as usize)
.checked_mul(cmd.height as usize)?
.checked_mul(cmd.format.bytes_per_pixel())
}
fn check_dimensions(cmd: &KittyGraphicsCommand) -> Result<(), GraphicError> {
if cmd.format == Format::Png {
return Ok(());
}
if cmd.width > MAX_DIMENSION || cmd.height > MAX_DIMENSION {
debug!(
"Rejecting kitty image: {}x{} exceeds {} cap",
cmd.width, cmd.height, MAX_DIMENSION
);
return Err(GraphicError::DimensionsTooLarge);
}
if cmd.width == 0 || cmd.height == 0 {
return Err(GraphicError::DimensionsRequired);
}
Ok(())
}
fn requested_size(cmd: &KittyGraphicsCommand) -> Result<usize, GraphicError> {
check_dimensions(cmd)?;
let size = if cmd.size > 0 {
cmd.size as usize
} else if cmd.compression != Compression::None {
0
} else {
raw_image_size(cmd).ok_or(GraphicError::TooLarge)?
};
if size > MAX_SIZE {
return Err(GraphicError::TooLarge);
}
Ok(size)
}
fn temp_dirs() -> &'static [std::path::PathBuf] {
static DIRS: std::sync::OnceLock<Vec<std::path::PathBuf>> =
std::sync::OnceLock::new();
DIRS.get_or_init(|| {
let mut dirs = vec![std::env::temp_dir()];
if cfg!(unix) {
dirs.push("/tmp".into());
dirs.push("/dev/shm".into());
}
dirs.into_iter()
.filter_map(|dir| std::fs::canonicalize(dir).ok())
.map(strip_verbatim)
.collect()
})
}
fn strip_verbatim(path: std::path::PathBuf) -> std::path::PathBuf {
#[cfg(windows)]
if let Some(rest) = path.to_str().and_then(|p| p.strip_prefix(r"\\?\")) {
return rest.into();
}
path
}
fn temp_entry(path: &std::path::Path) -> Option<std::path::PathBuf> {
let name = path.file_name()?;
let parent = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => std::path::Path::new("."),
};
let entry = strip_verbatim(std::fs::canonicalize(parent).ok()?).join(name);
is_temp_file_path(&entry).then_some(entry)
}
fn is_temp_file_path(path: &std::path::Path) -> bool {
path.to_string_lossy().contains("tty-graphics-protocol")
&& temp_dirs().iter().any(|dir| path.starts_with(dir))
}
fn is_sensitive_path(path: &std::path::Path) -> bool {
path.starts_with("/proc")
|| path.starts_with("/sys")
|| (path.starts_with("/dev") && !path.starts_with("/dev/shm"))
}
#[cfg(any(test, windows))]
fn is_unsafe_windows_path(path: &str) -> bool {
let bytes = path.as_bytes();
let is_sep = |c: u8| c == b'\\' || c == b'/';
if bytes.len() >= 2 && is_sep(bytes[0]) && is_sep(bytes[1]) {
return true;
}
if bytes.len() >= 4
&& is_sep(bytes[0])
&& bytes[1] == b'?'
&& bytes[2] == b'?'
&& is_sep(bytes[3])
{
return true;
}
path.rsplit(['\\', '/'])
.next()
.is_some_and(is_reserved_device_name)
}
#[cfg(any(test, windows))]
fn is_drive_absolute(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& bytes[2] == b'\\'
}
#[cfg(any(test, windows))]
fn is_reserved_device_name(component: &str) -> bool {
let component = match component.as_bytes() {
[drive, b':', ..] if drive.is_ascii_alphabetic() => &component[2..],
_ => component,
};
let end = component.find(['.', ':']).unwrap_or(component.len());
let stem = component[..end].trim_end_matches(' ');
if ["CONIN$", "CONOUT$", "CLOCK$"]
.iter()
.any(|name| stem.eq_ignore_ascii_case(name))
{
return true;
}
if stem.len() < 3 || !stem.is_char_boundary(3) {
return false;
}
let (base, rest) = stem.split_at(3);
if rest.is_empty() {
return ["CON", "PRN", "AUX", "NUL"]
.iter()
.any(|name| base.eq_ignore_ascii_case(name));
}
if !base.eq_ignore_ascii_case("COM") && !base.eq_ignore_ascii_case("LPT") {
return false;
}
(rest.len() == 1 && rest.as_bytes()[0].is_ascii_digit())
|| matches!(rest, "\u{b9}" | "\u{b2}" | "\u{b3}")
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn opened_path(file: &std::fs::File) -> Option<std::path::PathBuf> {
use std::os::unix::io::AsRawFd;
std::fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd())).ok()
}
#[cfg(target_os = "macos")]
fn opened_path(file: &std::fs::File) -> Option<std::path::PathBuf> {
use std::os::unix::ffi::OsStringExt;
use std::os::unix::io::AsRawFd;
let mut buf = vec![0u8; libc::PATH_MAX as usize];
if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETPATH, buf.as_mut_ptr()) } == -1 {
return None;
}
let len = buf.iter().position(|&b| b == 0)?;
buf.truncate(len);
Some(std::ffi::OsString::from_vec(buf).into())
}
#[cfg(windows)]
fn opened_path(file: &std::fs::File) -> Option<std::path::PathBuf> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
GetFinalPathNameByHandleW, FILE_NAME_NORMALIZED, VOLUME_NAME_DOS,
};
let mut buf = vec![0u16; 512];
let len = loop {
let len = unsafe {
GetFinalPathNameByHandleW(
file.as_raw_handle() as _,
buf.as_mut_ptr(),
buf.len() as u32,
FILE_NAME_NORMALIZED | VOLUME_NAME_DOS,
)
} as usize;
if len == 0 {
return None;
}
if len < buf.len() {
break len;
}
if buf.len() >= 32768 {
return None;
}
buf.resize(len.min(32768), 0);
};
let path = String::from_utf16(&buf[..len]).ok()?;
Some(strip_verbatim(path.into()))
}
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
windows
)))]
fn opened_path(_file: &std::fs::File) -> Option<std::path::PathBuf> {
None
}
#[cfg(windows)]
fn is_local_drive(path: &str) -> bool {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW;
const DRIVE_REMOTE: u32 = 4;
if !is_drive_absolute(path) {
return false;
}
let root: Vec<u16> = std::ffi::OsStr::new(&path[..3])
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe { GetDriveTypeW(root.as_ptr()) != DRIVE_REMOTE }
}
#[cfg(windows)]
fn local_windows_file(
path: &std::path::Path,
) -> Result<std::path::PathBuf, GraphicError> {
let raw = path.to_str().ok_or(GraphicError::InvalidData)?;
if is_unsafe_windows_path(raw) {
return Err(GraphicError::InvalidData);
}
let path = std::path::absolute(path).map_err(|_| GraphicError::InvalidData)?;
let path_str = path.to_str().ok_or(GraphicError::InvalidData)?;
if !is_local_drive(path_str) || is_unsafe_windows_path(path_str) {
return Err(GraphicError::InvalidData);
}
for dir in path.ancestors().skip(1) {
if dir.parent().is_none() {
break;
}
if !std::fs::symlink_metadata(dir).is_ok_and(|m| m.file_type().is_symlink()) {
continue;
}
let target = std::fs::read_link(dir)
.ok()
.map(strip_verbatim)
.and_then(|t| t.to_str().map(is_local_drive));
if target != Some(true) {
return Err(GraphicError::InvalidData);
}
}
match std::fs::symlink_metadata(&path) {
Ok(m) if m.is_file() && !m.file_type().is_symlink() => Ok(path),
Ok(_) => Err(GraphicError::InvalidData),
Err(_) => Err(GraphicError::FileNotFound),
}
}
fn open_image_file(
path: &std::path::Path,
delete: bool,
) -> Result<std::fs::File, GraphicError> {
#[cfg(windows)]
let path = &local_windows_file(path)?;
#[cfg(not(windows))]
{
let absolute =
std::path::absolute(path).map_err(|_| GraphicError::FileNotFound)?;
if is_sensitive_path(&absolute) {
return Err(GraphicError::InvalidData);
}
let resolved =
std::fs::canonicalize(path).map_err(|_| GraphicError::FileNotFound)?;
if is_sensitive_path(&resolved) {
return Err(GraphicError::InvalidData);
}
if !std::fs::metadata(&resolved).is_ok_and(|m| m.is_file()) {
return Err(GraphicError::FileNotFound);
}
}
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::custom_flags(
&mut options,
libc::O_NONBLOCK | libc::O_NOCTTY,
);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{
DELETE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ,
};
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
if delete {
options.access_mode(FILE_GENERIC_READ | DELETE);
}
}
#[cfg(not(windows))]
let _ = delete;
let file = match options.open(path) {
Ok(file) => file,
#[cfg(windows)]
Err(_) if delete => {
use std::os::windows::fs::OpenOptionsExt;
options
.access_mode(windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ);
options.open(path).map_err(|_| GraphicError::FileNotFound)?
}
Err(_) => return Err(GraphicError::FileNotFound),
};
Ok(file)
}
#[cfg(unix)]
fn names_open_file(path: &std::path::Path, file: &std::fs::File) -> bool {
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(path), file.metadata()) {
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
_ => false,
}
}
#[cfg(not(any(unix, windows)))]
fn names_open_file(_path: &std::path::Path, _file: &std::fs::File) -> bool {
true
}
fn validate_open_file(
file: &std::fs::File,
path: &std::path::Path,
) -> Result<std::path::PathBuf, GraphicError> {
#[cfg(windows)]
let _ = path;
#[cfg(windows)]
let real = {
if file.metadata().is_ok_and(|m| m.file_type().is_symlink()) {
return Err(GraphicError::InvalidData);
}
let real = opened_path(file).ok_or(GraphicError::InvalidData)?;
if !real.to_str().is_some_and(is_drive_absolute) {
return Err(GraphicError::InvalidData);
}
real
};
#[cfg(not(windows))]
let real = {
let real = opened_path(file)
.or_else(|| std::fs::canonicalize(path).ok())
.ok_or(GraphicError::FileNotFound)?;
if !names_open_file(&real, file) {
return Err(GraphicError::InvalidData);
}
real
};
if is_sensitive_path(&real) {
return Err(GraphicError::InvalidData);
}
Ok(real)
}
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
fn remove_temp_file(file: std::fs::File, path: &std::path::Path) {
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsRawFd;
drop(file);
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
return;
};
let Ok(name) = std::ffi::CString::new(name.as_bytes()) else {
return;
};
#[cfg(any(target_os = "linux", target_os = "android"))]
let flags = libc::O_DIRECTORY | libc::O_PATH;
#[cfg(target_os = "macos")]
let flags = libc::O_DIRECTORY;
let Ok(dir) = std::fs::OpenOptions::new()
.read(true)
.custom_flags(flags)
.open(parent)
else {
let _ = std::fs::remove_file(path);
return;
};
match opened_path(&dir) {
Some(resolved) if resolved == parent => unsafe {
libc::unlinkat(dir.as_raw_fd(), name.as_ptr(), 0);
},
Some(_) => {}
None => {
let _ = std::fs::remove_file(path);
}
}
}
#[cfg(windows)]
fn remove_temp_file(file: std::fs::File, path: &std::path::Path) {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO,
};
let info = FILE_DISPOSITION_INFO { DeleteFile: true };
let marked = unsafe {
SetFileInformationByHandle(
file.as_raw_handle() as _,
FileDispositionInfo,
&info as *const FILE_DISPOSITION_INFO as *const _,
std::mem::size_of::<FILE_DISPOSITION_INFO>() as u32,
)
} != 0;
drop(file);
if !marked {
let _ = std::fs::remove_file(path);
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
windows
)))]
fn remove_temp_file(file: std::fs::File, path: &std::path::Path) {
drop(file);
let _ = std::fs::remove_file(path);
}
fn read_file_data(
file: &mut std::fs::File,
offset: u32,
size: usize,
) -> Result<Vec<u8>, GraphicError> {
use std::io::{Read, Seek, SeekFrom};
let len = match file.metadata() {
Ok(m) if m.is_file() => m.len(),
_ => return Err(GraphicError::FileNotFound),
};
file.seek(SeekFrom::Start(offset as u64))
.map_err(|_| GraphicError::InvalidData)?;
let limit = if size > 0 { size } else { MAX_SIZE + 1 };
let available =
usize::try_from(len.saturating_sub(offset as u64)).unwrap_or(usize::MAX);
let mut data = Vec::with_capacity(limit.min(available));
file.take(limit as u64)
.read_to_end(&mut data)
.map_err(|_| GraphicError::InvalidData)?;
if data.len() > MAX_SIZE {
return Err(GraphicError::TooLarge);
}
if data.is_empty() || (size > 0 && data.len() != size) {
return Err(GraphicError::InvalidData);
}
Ok(data)
}
#[cfg(windows)]
struct WinHandleGuard(windows_sys::Win32::Foundation::HANDLE);
#[cfg(windows)]
impl Drop for WinHandleGuard {
fn drop(&mut self) {
unsafe {
windows_sys::Win32::Foundation::CloseHandle(self.0);
}
}
}
#[cfg(windows)]
struct WinViewGuard(windows_sys::Win32::System::Memory::MEMORY_MAPPED_VIEW_ADDRESS);
#[cfg(windows)]
impl Drop for WinViewGuard {
fn drop(&mut self) {
unsafe {
windows_sys::Win32::System::Memory::UnmapViewOfFile(self.0);
}
}
}
fn create_graphic_data(cmd: &KittyGraphicsCommand) -> Result<GraphicData, GraphicError> {
if !matches!(
cmd.medium,
TransmissionMedium::TempFile | TransmissionMedium::SharedMemory
) {
check_dimensions(cmd)?;
}
let raw_data = match cmd.medium {
TransmissionMedium::Direct => {
if cmd.payload.len() > MAX_SIZE {
return Err(GraphicError::TooLarge);
}
debug!("Using decoded Direct payload: {} bytes", cmd.payload.len());
cmd.payload.to_vec()
}
TransmissionMedium::File | TransmissionMedium::TempFile => {
use std::path::Path;
debug!("File path payload: {} bytes", cmd.payload.len());
let path_str = std::str::from_utf8(&cmd.payload)
.map_err(|_| GraphicError::InvalidData)?;
debug!("File path: {}", path_str);
let path = Path::new(path_str);
let temp = cmd.medium == TransmissionMedium::TempFile;
let mut file = open_image_file(path, temp)?;
let entry = if temp { temp_entry(path) } else { None };
let result = validate_open_file(&file, path)
.and_then(|real| {
if temp && (entry.is_none() || !is_temp_file_path(&real)) {
return Err(GraphicError::InvalidData);
}
requested_size(cmd)
})
.and_then(|size| read_file_data(&mut file, cmd.offset, size));
match entry {
Some(entry) => remove_temp_file(file, &entry),
None => drop(file),
}
result?
}
TransmissionMedium::SharedMemory => {
#[cfg(all(unix, not(target_os = "android")))]
{
use std::ffi::CString;
use std::os::unix::io::RawFd;
debug!("Shared memory name payload: {} bytes", cmd.payload.len());
let shm_name_str = std::str::from_utf8(&cmd.payload)
.map_err(|_| GraphicError::InvalidData)?;
if !valid_shm_name(shm_name_str) {
return Err(GraphicError::InvalidData);
}
let shm_name =
CString::new(shm_name_str).map_err(|_| GraphicError::InvalidData)?;
debug!("Opening shared memory: {}", shm_name_str);
unsafe {
let fd: RawFd = libc::shm_open(shm_name.as_ptr(), libc::O_RDONLY, 0);
if fd < 0 {
let err = std::io::Error::last_os_error();
let errno = err.raw_os_error().unwrap_or(-1);
debug!(
"Failed to open shared memory '{}': {} (errno: {})",
shm_name_str, err, errno
);
return Err(GraphicError::FileNotFound);
}
let _shm = ShmGuard {
fd,
name: &shm_name,
};
let mut stat: libc::stat = std::mem::zeroed();
if libc::fstat(fd, &mut stat) < 0 {
debug!("Failed to fstat shared memory");
return Err(GraphicError::InvalidData);
}
let shm_size = usize::try_from(stat.st_size)
.map_err(|_| GraphicError::InvalidData)?;
debug!("Shared memory size: {} bytes", shm_size);
let range = shm_range(cmd, shm_size)?;
let page_size = match libc::sysconf(libc::_SC_PAGESIZE) {
n if n > 0 => n as usize,
_ => return Err(GraphicError::InvalidData),
};
let map_start = range.start - range.start % page_size;
let map_len = range.end - map_start;
let ptr = libc::mmap(
std::ptr::null_mut(),
map_len,
libc::PROT_READ,
libc::MAP_SHARED,
fd,
map_start as libc::off_t,
);
if ptr == libc::MAP_FAILED {
debug!("Failed to mmap shared memory");
return Err(GraphicError::InvalidData);
}
let _map = MapGuard { ptr, len: map_len };
let mapped = std::slice::from_raw_parts(ptr as *const u8, map_len);
let data = mapped[range.start - map_start..].to_vec();
debug!("Successfully read {} bytes from shared memory", data.len());
data
}
}
#[cfg(windows)]
{
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::System::Memory::OpenFileMappingW;
use windows_sys::Win32::System::Memory::{
MapViewOfFile, VirtualQuery, FILE_MAP_READ, MEMORY_BASIC_INFORMATION,
};
debug!("Shared memory name payload: {} bytes", cmd.payload.len());
let shm_name_str = std::str::from_utf8(&cmd.payload)
.map_err(|_| GraphicError::InvalidData)?;
debug!("Opening shared memory: {}", shm_name_str);
unsafe {
let wide_name: Vec<u16> = OsStr::new(shm_name_str)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let handle = OpenFileMappingW(FILE_MAP_READ, 0, wide_name.as_ptr());
if handle.is_null() {
let err = std::io::Error::last_os_error();
debug!(
"Failed to open shared memory '{}': {}",
shm_name_str, err
);
return Err(GraphicError::FileNotFound);
}
let _handle = WinHandleGuard(handle);
let base_ptr = MapViewOfFile(handle, FILE_MAP_READ, 0, 0, 0);
if base_ptr.Value.is_null() {
let err = std::io::Error::last_os_error();
debug!("Failed to map view of file: {}", err);
return Err(GraphicError::InvalidData);
}
let _view = WinViewGuard(base_ptr);
let mut mem_info: MEMORY_BASIC_INFORMATION = std::mem::zeroed();
if VirtualQuery(
base_ptr.Value,
&mut mem_info,
std::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
) == 0
{
debug!("Failed to query memory information");
return Err(GraphicError::InvalidData);
}
let shm_size = mem_info.RegionSize;
debug!("Shared memory size: {} bytes", shm_size);
let range = shm_range(cmd, shm_size)?;
let data =
std::slice::from_raw_parts(base_ptr.Value as *const u8, shm_size)
[range]
.to_vec();
debug!("Successfully read {} bytes from shared memory", data.len());
data
}
}
#[cfg(any(target_os = "android", not(any(unix, windows))))]
{
debug!("SharedMemory transmission not supported on this platform");
return Err(GraphicError::UnsupportedMedium);
}
}
};
let pixel_data = match cmd.compression {
Compression::None => raw_data,
Compression::Zlib => {
use flate2::read::ZlibDecoder;
use std::io::Read;
let decoder = ZlibDecoder::new(&raw_data[..]);
let mut decompressed = Vec::new();
decoder
.take((MAX_SIZE as u64).saturating_add(1))
.read_to_end(&mut decompressed)
.map_err(|_| GraphicError::DecompressionFailed)?;
if decompressed.len() > MAX_SIZE {
return Err(GraphicError::TooLarge);
}
decompressed
}
};
match cmd.format {
Format::Png => {
#[cfg(not(feature = "graphics"))]
let result = {
let _ = &pixel_data;
Err(GraphicError::InvalidData)
};
#[cfg(feature = "graphics")]
let result = {
use image_rs::ImageFormat;
debug!("Decoding PNG, pixel_data length: {}", pixel_data.len());
let img = match image_rs::load_from_memory_with_format(
&pixel_data,
ImageFormat::Png,
) {
Ok(img) => {
debug!(
"PNG decoded successfully: {}x{}",
img.width(),
img.height()
);
img
}
Err(e) => {
debug!("PNG decode failed: {:?}", e);
return Err(GraphicError::InvalidData);
}
};
if img.width() > MAX_DIMENSION || img.height() > MAX_DIMENSION {
return Err(GraphicError::DimensionsTooLarge);
}
let rgba_img = img.to_rgba8();
let (width, height) =
(rgba_img.width() as usize, rgba_img.height() as usize);
let pixels = rgba_img.into_raw();
let is_opaque = pixels.chunks(4).all(|chunk| chunk[3] == 255);
let resize = if cmd.columns > 0 || cmd.rows > 0 {
let both_specified = cmd.columns > 0 && cmd.rows > 0;
Some(ResizeCommand {
width: if cmd.columns > 0 {
ResizeParameter::Cells(cmd.columns)
} else {
ResizeParameter::Auto
},
height: if cmd.rows > 0 {
ResizeParameter::Cells(cmd.rows)
} else {
ResizeParameter::Auto
},
preserve_aspect_ratio: !both_specified,
})
} else {
None
};
Ok(GraphicData {
id: GraphicId::new(cmd.image_id as u64),
width,
height,
color_type: ColorType::Rgba,
pixels,
is_opaque,
resize,
display_width: None,
display_height: None,
transmit_time: crate::time::Instant::now(),
})
};
result
}
Format::Gray | Format::GrayAlpha | Format::Rgb24 | Format::Rgba32 => {
let expected_size = raw_image_size(cmd).ok_or(GraphicError::TooLarge)?;
if pixel_data.len() < expected_size {
debug!(
"Pixel data size insufficient: got {} bytes, expected at least {}",
pixel_data.len(),
expected_size
);
return Err(GraphicError::InvalidData);
}
let pixel_data = if pixel_data.len() > expected_size {
pixel_data[..expected_size].to_vec()
} else {
pixel_data
};
let (pixels, is_opaque) = match cmd.format {
Format::Gray => {
let mut rgba =
Vec::with_capacity(cmd.width as usize * cmd.height as usize * 4);
for &g in &pixel_data {
rgba.extend_from_slice(&[g, g, g, 255]);
}
(rgba, true)
}
Format::GrayAlpha => {
let mut rgba =
Vec::with_capacity(cmd.width as usize * cmd.height as usize * 4);
let mut opaque = true;
for chunk in pixel_data.as_chunks::<2>().0 {
let g = chunk[0];
let a = chunk[1];
if a != 255 {
opaque = false;
}
rgba.extend_from_slice(&[g, g, g, a]);
}
(rgba, opaque)
}
Format::Rgb24 => {
let mut rgba =
Vec::with_capacity(cmd.width as usize * cmd.height as usize * 4);
for chunk in pixel_data.as_chunks::<3>().0 {
rgba.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
}
(rgba, true)
}
Format::Rgba32 => {
let is_opaque = pixel_data.chunks(4).all(|chunk| chunk[3] == 255);
(pixel_data, is_opaque)
}
_ => unreachable!(),
};
let resize = if cmd.columns > 0 || cmd.rows > 0 {
let both_specified = cmd.columns > 0 && cmd.rows > 0;
Some(ResizeCommand {
width: if cmd.columns > 0 {
ResizeParameter::Cells(cmd.columns)
} else {
ResizeParameter::Auto
},
height: if cmd.rows > 0 {
ResizeParameter::Cells(cmd.rows)
} else {
ResizeParameter::Auto
},
preserve_aspect_ratio: !both_specified,
})
} else {
None
};
Ok(GraphicData {
id: GraphicId::new(cmd.image_id as u64),
width: cmd.width as usize,
height: cmd.height as usize,
color_type: ColorType::Rgba, pixels,
is_opaque,
resize,
display_width: None,
display_height: None,
transmit_time: crate::time::Instant::now(),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
fn parse_kitty_graphics_protocol(
keys: &str,
payload: &str,
) -> Option<KittyGraphicsResponse> {
let params = if keys.is_empty() && payload.is_empty() {
vec![b"G".as_ref()]
} else if payload.is_empty() {
vec![b"G".as_ref(), keys.as_bytes()]
} else {
vec![b"G".as_ref(), keys.as_bytes(), payload.as_bytes()]
};
let mut state = KittyGraphicsState::default();
parse(¶ms, &mut state)
}
#[test]
fn test_parse_basic_transmit() {
let payload = "/wAA/w==";
let result = parse_kitty_graphics_protocol("a=t,f=32,s=1,v=1", payload);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
assert!(response.placement_request.is_none());
assert!(response.delete_request.is_none());
}
#[test]
fn test_parse_transmit_and_display() {
let payload = "/wAA/w==";
let result = parse_kitty_graphics_protocol("a=T,f=32,s=1,v=1,i=1", payload);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
assert!(response.placement_request.is_some());
let placement = response.placement_request.unwrap();
assert_eq!(placement.image_id, 1);
}
#[test]
fn test_parse_placement() {
let result = parse_kitty_graphics_protocol("a=p,i=1,x=10,y=20,c=5,r=3,z=2", "");
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_none());
assert!(response.placement_request.is_some());
let placement = response.placement_request.unwrap();
assert_eq!(placement.image_id, 1);
assert_eq!(placement.x, 10);
assert_eq!(placement.y, 20);
assert_eq!(placement.columns, 5);
assert_eq!(placement.rows, 3);
assert_eq!(placement.z_index, 2);
}
#[test]
fn test_parse_placement_subcell_offset() {
let result = parse_kitty_graphics_protocol("a=p,i=1,X=7,Y=9", "");
let placement = result
.expect("parse ok")
.placement_request
.expect("placement");
assert_eq!(placement.cell_x_offset, 7);
assert_eq!(placement.cell_y_offset, 9);
}
#[test]
fn test_parse_placement_subcell_offset_defaults_zero() {
let result = parse_kitty_graphics_protocol("a=p,i=1", "");
let placement = result
.expect("parse ok")
.placement_request
.expect("placement");
assert_eq!(placement.cell_x_offset, 0);
assert_eq!(placement.cell_y_offset, 0);
}
#[test]
fn test_parse_delete() {
let result = parse_kitty_graphics_protocol("a=d,d=i,i=1", "");
assert!(result.is_some());
let response = result.unwrap();
assert!(response.delete_request.is_some());
let delete = response.delete_request.unwrap();
assert_eq!(delete.action, b'i');
assert_eq!(delete.image_id, 1);
assert!(!delete.delete_data);
}
#[test]
fn test_parse_delete_uppercase() {
let result = parse_kitty_graphics_protocol("a=d,d=I,i=1", "");
assert!(result.is_some());
let response = result.unwrap();
assert!(response.delete_request.is_some());
let delete = response.delete_request.unwrap();
assert_eq!(delete.action, b'i');
assert_eq!(delete.image_id, 1);
assert!(delete.delete_data);
}
#[test]
fn test_parse_query() {
let result = parse_kitty_graphics_protocol("a=q,i=1,f=32,s=1,v=1", "AAAAAA==");
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_none(), "queries must not store");
let body = response.response.expect("response expected");
assert!(body.contains(";OK"), "valid query must succeed: {body}");
let result = parse_kitty_graphics_protocol("a=q,i=1", "");
let body = result
.expect("response struct must exist")
.response
.expect("error response expected");
assert!(
!body.contains(";OK"),
"undecodable query must not report OK: {body}"
);
}
#[test]
fn test_parse_with_compression() {
let payload = "eJz7z8DwHwAE/wH/";
let result = parse_kitty_graphics_protocol("a=t,f=32,s=1,v=1,o=z", payload);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
}
#[test]
fn test_parse_with_unicode_placeholder() {
let result = parse_kitty_graphics_protocol("a=p,i=1,u=128512", ""); assert!(result.is_some());
let response = result.unwrap();
assert!(response.placement_request.is_some());
let placement = response.placement_request.unwrap();
assert_eq!(placement.unicode_placeholder, 128512);
assert!(!placement.virtual_placement);
}
#[test]
fn test_parse_with_virtual_placement() {
let result = parse_kitty_graphics_protocol("a=p,U=1,i=42,c=10,r=4,q=2", "");
let response = result.expect("parse ok");
let placement = response.placement_request.expect("placement");
assert!(placement.virtual_placement);
assert_eq!(placement.image_id, 42);
assert_eq!(placement.columns, 10);
assert_eq!(placement.rows, 4);
assert_eq!(placement.unicode_placeholder, 0);
}
#[cfg(feature = "graphics")]
#[test]
fn test_parse_png_format() {
let png_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
let result = parse_kitty_graphics_protocol("a=t,f=100,i=1", png_data);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
}
#[test]
fn test_animation_frame_returns_unsupported_error() {
let payload = "AAAA";
let result = parse_kitty_graphics_protocol("a=f,i=1,r=2,s=1,v=1,f=32", payload);
let response = result.expect("animation actions must produce a response");
assert!(response.graphic_data.is_none());
assert!(response.placement_request.is_none());
assert!(response.delete_request.is_none());
let body = response.response.expect("error response expected");
assert!(
body.contains("i=1"),
"response should echo image id: {body}"
);
assert!(
body.contains("EINVAL:unsupported action"),
"response should contain EINVAL: {body}"
);
assert!(body.starts_with("\x1b_G"), "response should be APC: {body}");
assert!(
body.ends_with("\x1b\\"),
"response should end with ST: {body}"
);
}
#[test]
fn test_animation_control_returns_unsupported_error() {
let result = parse_kitty_graphics_protocol("a=a,i=42,s=3", "");
let response = result.expect("animate action must produce a response");
let body = response.response.expect("error response expected");
assert!(body.contains("i=42"));
assert!(body.contains("EINVAL:unsupported action"));
}
#[test]
fn test_animation_compose_returns_unsupported_error() {
let result = parse_kitty_graphics_protocol("a=c,i=7,r=1,c=2", "");
let response = result.expect("compose action must produce a response");
let body = response.response.expect("error response expected");
assert!(body.contains("i=7"));
assert!(body.contains("EINVAL:unsupported action"));
}
#[test]
fn test_animation_error_uses_image_number_when_no_id() {
let result = parse_kitty_graphics_protocol("a=f,I=99,r=2,s=1,v=1,f=32", "AAAA");
let response = result.expect("animation action must produce a response");
let body = response.response.expect("error response expected");
assert!(body.contains("I=99"), "expected I=99 in {body}");
assert!(body.contains("EINVAL:unsupported action"));
}
#[test]
fn test_animation_error_suppressed_when_quiet_2() {
let result =
parse_kitty_graphics_protocol("a=f,i=1,r=2,s=1,v=1,f=32,q=2", "AAAA");
let response = result.expect("response struct should still exist");
assert!(
response.response.is_none(),
"q=2 should suppress error response"
);
}
#[test]
fn test_parse_invalid_action() {
let result = parse_kitty_graphics_protocol("a=x", "");
assert!(result.is_some()); }
#[test]
fn test_parse_empty_keys() {
let mut state = KittyGraphicsState::default();
let result = parse(&[], &mut state);
assert!(result.is_none());
let result = parse(&[b"G"], &mut state);
let response = result.expect("response struct must exist even on error");
assert!(response.graphic_data.is_none());
assert!(
response.response.is_none(),
"implicit-id failure must not emit a response"
);
let result = parse(&[b"G", b""], &mut state);
assert!(result.is_some());
}
#[test]
fn test_incomplete_image_accumulation() {
let mut state = KittyGraphicsState::default();
let params1 = vec![b"G".as_ref(), b"a=t,f=32,s=1,v=1,m=1,i=100", b"/wAA"];
let result1 = parse(¶ms1, &mut state).expect(
"intermediate chunks must return a `pending_chunk` response, not None",
);
assert!(result1.incomplete, "first chunk must be marked incomplete");
assert!(result1.graphic_data.is_none());
let params2 = vec![b"G".as_ref(), b"a=t,f=32,s=1,v=1,m=0,i=100", b"/w=="];
let result2 = parse(¶ms2, &mut state);
let response = result2.expect("final chunk must produce a response");
assert!(!response.incomplete, "final chunk must not be incomplete");
let graphic = response
.graphic_data
.expect("final chunk must produce graphic data");
assert_eq!(
graphic.pixels,
vec![0xFF, 0x00, 0x00, 0xFF],
"decoded bytes must equal the full decoded payload"
);
}
#[test]
fn test_incomplete_image_accumulation_padded_chunks() {
let mut state = KittyGraphicsState::default();
let full_binary: Vec<u8> = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA];
let expected_pixels = {
let mut rgba = Vec::with_capacity(full_binary.len() * 4);
for &g in &full_binary {
rgba.extend_from_slice(&[g, g, g, 255]);
}
rgba
};
let params1 = vec![b"G".as_ref(), b"a=t,f=8,s=7,v=1,m=1,i=200", b"3q2+7w=="];
let r1 = parse(¶ms1, &mut state)
.expect("padded intermediate chunk must not return None");
assert!(r1.incomplete);
let params2 = vec![b"G".as_ref(), b"a=t,f=8,s=7,v=1,m=0,i=200", b"yv66"];
let r2 = parse(¶ms2, &mut state).expect("final chunk must parse");
assert!(!r2.incomplete);
let graphic = r2.graphic_data.expect("graphic data must be produced");
assert_eq!(
graphic.pixels, expected_pixels,
"chafa-style padded chunks must merge into the correct byte stream",
);
}
#[test]
fn test_chunked_matches_single_shot() {
let single_pixel_b64 = "/wAA/w==";
let single =
parse_kitty_graphics_protocol("a=t,f=32,s=1,v=1,i=301", single_pixel_b64)
.and_then(|r| r.graphic_data)
.expect("single-shot must succeed");
let mut state = KittyGraphicsState::default();
let p1 = vec![b"G".as_ref(), b"a=t,f=32,s=1,v=1,m=1,i=302", b"/wAA"];
parse(&p1, &mut state).expect("first chunk");
let p2 = vec![b"G".as_ref(), b"a=t,f=32,s=1,v=1,m=0,i=302", b"/w=="];
let chunked = parse(&p2, &mut state)
.and_then(|r| r.graphic_data)
.expect("chunked must succeed");
assert_eq!(
single.pixels, chunked.pixels,
"chunked and single-shot decode must agree"
);
assert_eq!(single.width, chunked.width);
assert_eq!(single.height, chunked.height);
}
#[test]
fn test_chunked_preserves_first_chunk_metadata() {
let mut state = KittyGraphicsState::default();
let p1 = vec![b"G".as_ref(), b"a=T,f=32,s=1,v=1,i=500,z=7,m=1", b"/wAA"];
parse(&p1, &mut state).expect("first chunk");
let p2 = vec![b"G".as_ref(), b"m=0,i=500", b"/w=="];
let response =
parse(&p2, &mut state).expect("final chunk must produce a response");
let graphic = response.graphic_data.expect("graphic data");
assert_eq!(graphic.width, 1);
assert_eq!(graphic.height, 1);
assert_eq!(graphic.pixels, vec![0xFF, 0x00, 0x00, 0xFF]);
let placement = response
.placement_request
.expect("a=T must emit a placement request");
assert_eq!(placement.z_index, 7);
}
#[test]
fn test_chunked_with_zlib_compression() {
use flate2::write::ZlibEncoder;
use flate2::Compression as FlateCompression;
use std::io::Write;
let raw = vec![0xFF, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF];
let mut encoder = ZlibEncoder::new(Vec::new(), FlateCompression::default());
encoder.write_all(&raw).unwrap();
let compressed = encoder.finish().unwrap();
let encoded = BASE64.encode(&compressed);
assert!(encoded.len() >= 4, "test payload should be chunkable");
let split_at = (encoded.len() / 2) & !0x3; let (first, second) = encoded.split_at(split_at);
let mut state = KittyGraphicsState::default();
let p1_ctrl = String::from("a=t,f=32,s=2,v=1,o=z,m=1,i=600");
let p1 = vec![b"G".as_ref(), p1_ctrl.as_bytes(), first.as_bytes()];
parse(&p1, &mut state).expect("first chunk");
let p2_ctrl = String::from("m=0,i=600");
let p2 = vec![b"G".as_ref(), p2_ctrl.as_bytes(), second.as_bytes()];
let response =
parse(&p2, &mut state).expect("final chunk must parse and decompress");
let graphic = response.graphic_data.expect("graphic data");
assert_eq!(
graphic.pixels, raw,
"zlib-compressed chunked payload must decompress to original"
);
}
#[test]
fn test_max_dimension_rejects_oversized_images() {
let result = parse_kitty_graphics_protocol("a=t,f=32,s=10001,v=1,i=1", "AAAA")
.expect("response struct must exist");
assert!(result.graphic_data.is_none());
let body = result.response.expect("error message expected");
assert!(
body.contains("dimensions too large"),
"expected dimensions-too-large: {body}"
);
let result = parse_kitty_graphics_protocol("a=t,f=32,s=1,v=10001,i=1", "AAAA")
.expect("response struct must exist");
assert!(result.graphic_data.is_none());
let body = result.response.expect("error message expected");
assert!(body.contains("dimensions too large"));
let result = parse_kitty_graphics_protocol("a=t,f=32,s=10000,v=1,i=1", "AAAA")
.expect("response struct must exist");
let body = result.response.unwrap_or_default();
assert!(
!body.contains("dimensions too large"),
"10000 must not trip the dimension cap: {body}"
);
}
#[test]
fn test_iid_mutually_exclusive() {
let result = parse_kitty_graphics_protocol("a=t,f=32,s=1,v=1,i=5,I=6", "AAAA")
.expect("response struct must exist");
assert!(result.graphic_data.is_none());
let body = result.response.expect("error message expected");
assert!(
body.contains("mutually exclusive"),
"expected mutually-exclusive error: {body}"
);
assert!(body.contains("i=5"));
assert!(body.contains("I=6"));
}
#[test]
fn test_query_requires_image_id() {
let result =
parse_kitty_graphics_protocol("a=q", "").expect("response struct must exist");
assert!(result.response.is_none());
let result = parse_kitty_graphics_protocol("a=q,I=7", "")
.expect("response struct must exist");
let body = result.response.expect("error message expected");
assert!(
body.contains("image ID required"),
"expected image-id-required error: {body}"
);
assert!(body.contains("I=7"));
let result = parse_kitty_graphics_protocol("a=q,i=42,f=32,s=1,v=1", "AAAAAA==")
.expect("response struct must exist");
let body = result.response.expect("OK response expected");
assert!(body.contains("i=42"));
assert!(body.contains(";OK"));
}
#[cfg(feature = "graphics")]
#[test]
fn test_response_combines_image_number_and_placement() {
let png_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
let result = parse_kitty_graphics_protocol("a=T,f=100,i=7,p=13", png_data)
.expect("response struct must exist");
let body = result.response.expect("OK response expected");
assert!(
body.contains("i=7") && body.contains("p=13"),
"both id and placement must appear: {body}"
);
assert!(body.contains(";OK"));
}
#[test]
fn test_animation_params_are_parsed() {
let mut cmd = KittyGraphicsCommand::default();
parse_control_data(&mut cmd, "a=f,i=1,r=3,c=2,z=100,X=1,Y=4294901760");
assert_eq!(cmd.action, Action::Frame);
assert_eq!(cmd.frame_number, 3);
assert_eq!(cmd.base_frame, 2);
assert_eq!(cmd.frame_gap, 100);
assert_eq!(cmd.composition_mode, 1);
assert_eq!(cmd.background_color, 4_294_901_760);
let mut cmd = KittyGraphicsCommand::default();
parse_control_data(&mut cmd, "a=a,i=2,s=3,v=5,c=1,r=2,z=50");
assert_eq!(cmd.action, Action::Animate);
assert_eq!(cmd.animation_state, 3);
assert_eq!(cmd.loop_count, 5);
assert_eq!(cmd.current_frame, 1);
assert_eq!(cmd.frame_number, 2);
assert_eq!(cmd.frame_gap, 50);
}
fn parse_delete(keys: &str) -> DeleteRequest {
let resp = parse_kitty_graphics_protocol(keys, "")
.expect("delete must produce a response");
resp.delete_request
.expect("delete_request must be populated")
}
#[test]
fn test_delete_variants_all_parse() {
let d = parse_delete("a=d,d=a");
assert_eq!(d.action, b'a');
assert!(!d.delete_data);
let d = parse_delete("a=d,d=A");
assert_eq!(d.action, b'a');
assert!(d.delete_data, "uppercase form must set delete_data=true");
let d = parse_delete("a=d,d=i,i=7");
assert_eq!(d.action, b'i');
assert_eq!(d.image_id, 7);
let d = parse_delete("a=d,d=n,I=11");
assert_eq!(d.action, b'n');
assert_eq!(
d.image_number, 11,
"d=n must pick up the number from I=, not i="
);
let d = parse_delete("a=d,d=C");
assert_eq!(d.action, b'c');
assert!(d.delete_data);
let d = parse_delete("a=d,d=p,x=3,y=5");
assert_eq!(d.action, b'p');
assert_eq!(d.x, 3);
assert_eq!(d.y, 5);
let d = parse_delete("a=d,d=q,x=2,y=4,z=-3");
assert_eq!(d.action, b'q');
assert_eq!(d.z_index, -3);
let d = parse_delete("a=d,d=R,x=10,y=20");
assert_eq!(d.action, b'r');
assert_eq!(d.x, 10);
assert_eq!(d.y, 20);
assert!(d.delete_data);
let d = parse_delete("a=d,d=x,x=5");
assert_eq!(d.action, b'x');
assert_eq!(d.x, 5);
let d = parse_delete("a=d,d=Y,y=9");
assert_eq!(d.action, b'y');
assert_eq!(d.y, 9);
assert!(d.delete_data);
let d = parse_delete("a=d,d=z,z=42");
assert_eq!(d.action, b'z');
assert_eq!(d.z_index, 42);
}
#[test]
fn test_stale_chunk_eviction() {
let mut state = KittyGraphicsState::default();
let p1 = vec![b"G".as_ref(), b"a=t,f=32,s=1,v=1,m=1,i=777", b"/wAA"];
parse(&p1, &mut state).expect("first chunk");
assert_eq!(state.incomplete_images.len(), 1);
let stale = Instant::now() - CHUNK_STALE_TIMEOUT - Duration::from_secs(1);
if let Some(cmd) = state.incomplete_images.get_mut(&777) {
cmd.last_touched = stale;
}
let p2 = vec![b"G".as_ref(), b"a=t,f=32,s=1,v=1,i=888", b"/wAA/w=="];
let _ = parse(&p2, &mut state);
assert!(
!state.incomplete_images.contains_key(&777),
"stale chunk must have been evicted"
);
}
#[test]
fn test_padding_in_middle_of_raw_concat_would_fail() {
let concatenated = b"3q2+7w==yv66".as_slice();
assert!(
BASE64.decode(concatenated).is_err(),
"strict base64 must reject `=` in the middle of the input"
);
}
#[test]
fn test_pending_chunk_distinct_from_parse_error() {
let mut state = KittyGraphicsState::default();
let params = vec![b"G".as_ref(), b"a=t,f=32,s=1,v=1,m=1,i=42", b"/wAA"];
let resp = parse(¶ms, &mut state).expect("pending must be Some");
assert!(resp.incomplete);
assert!(resp.response.is_none());
let proc_path = BASE64.encode("/proc/self/environ".as_bytes());
let bad = vec![
b"G".as_ref(),
b"a=t,t=f,f=32,s=1,v=1,i=99",
proc_path.as_bytes(),
];
let resp = parse(&bad, &mut KittyGraphicsState::default())
.expect("real errors now carry a response, not None");
assert!(!resp.incomplete, "real errors must not look like pending");
assert!(resp.graphic_data.is_none());
let body = resp.response.expect("error message expected");
assert!(
body.contains("i=99"),
"error must be addressed to i=99: {body}"
);
assert!(
body.contains("EINVAL") || body.contains("ENOENT"),
"blocked path should surface EINVAL or ENOENT: {body}"
);
}
#[test]
fn test_file_transmission_medium() {
use std::io::Write;
let temp_path = std::env::temp_dir().join("test_kitty_image.rgba");
let temp_path = temp_path.to_str().unwrap();
let mut file = std::fs::File::create(temp_path).unwrap();
file.write_all(&[255, 0, 0, 255]).unwrap(); drop(file);
let encoded_path = BASE64.encode(temp_path.as_bytes());
let result =
parse_kitty_graphics_protocol("a=t,t=f,f=32,s=1,v=1,i=1", &encoded_path);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
let _ = std::fs::remove_file(temp_path);
}
#[test]
fn test_temp_file_transmission_medium() {
use std::io::Write;
let temp_path = std::env::temp_dir().join("tty-graphics-protocol-test.rgba");
let temp_path = temp_path.to_str().unwrap();
let mut file = std::fs::File::create(temp_path).unwrap();
file.write_all(&[255, 0, 0, 255]).unwrap(); drop(file);
let encoded_path = BASE64.encode(temp_path.as_bytes());
let result =
parse_kitty_graphics_protocol("a=t,t=t,f=32,s=1,v=1,i=1", &encoded_path);
assert!(!std::path::Path::new(temp_path).exists());
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
}
#[test]
fn test_security_checks() {
for path in &["/proc/self/environ", "/sys/class/net", "/dev/null"] {
let encoded = BASE64.encode(path.as_bytes());
let response =
parse_kitty_graphics_protocol("a=t,t=f,f=32,s=1,v=1,i=1", &encoded)
.expect("must return a response carrying the EINVAL");
assert!(response.graphic_data.is_none(), "{path} must not load");
let body = response.response.expect("error message expected");
assert!(body.contains("i=1"));
assert!(
body.contains("EINVAL") || body.contains("ENOENT"),
"blocked path should surface EINVAL/ENOENT: {body}"
);
}
}
#[test]
fn test_quiet_mode() {
let result = parse_kitty_graphics_protocol("a=p,i=1,q=1", "");
let response = result.expect("response struct must exist");
assert!(response.response.is_none(), "q=1 must suppress OK");
let result = parse_kitty_graphics_protocol("a=q,i=1,q=2", "");
let response = result.expect("response struct must exist");
assert!(response.response.is_none(), "q=2 must suppress all output");
}
#[test]
fn test_data_range() {
assert_eq!(data_range(0, 0, 4096), Some(0..4096));
assert_eq!(data_range(100, 4, 4096), Some(100..104));
assert_eq!(data_range(8192, 0, 16384), Some(8192..16384));
assert_eq!(data_range(4092, 4, 4096), Some(4092..4096));
assert_eq!(data_range(4096, 4096, 4096), None);
assert_eq!(data_range(4093, 4, 4096), None);
assert_eq!(data_range(8192, 0, 4096), None);
assert_eq!(data_range(4096, 0, 4096), None);
assert_eq!(data_range(0, 0, 0), None);
assert_eq!(data_range(u32::MAX, u32::MAX as usize, 4096), None);
}
#[cfg(all(unix, not(target_os = "android")))]
fn create_shm(name: &std::ffi::CStr, bytes: &[u8]) -> usize {
unsafe {
libc::shm_unlink(name.as_ptr());
let fd = libc::shm_open(
name.as_ptr(),
libc::O_CREAT | libc::O_RDWR | libc::O_EXCL,
0o600,
);
assert!(fd >= 0, "shm_open failed");
assert_eq!(libc::ftruncate(fd, bytes.len() as libc::off_t), 0);
let ptr = libc::mmap(
std::ptr::null_mut(),
bytes.len(),
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
fd,
0,
);
assert_ne!(ptr, libc::MAP_FAILED);
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr as *mut u8, bytes.len());
libc::munmap(ptr, bytes.len());
let mut stat: libc::stat = std::mem::zeroed();
assert_eq!(libc::fstat(fd, &mut stat), 0);
libc::close(fd);
stat.st_size as usize
}
}
#[cfg(all(unix, not(target_os = "android")))]
fn shm_exists(name: &std::ffi::CStr) -> bool {
unsafe {
let fd = libc::shm_open(name.as_ptr(), libc::O_RDONLY, 0);
if fd >= 0 {
libc::close(fd);
}
fd >= 0
}
}
#[cfg(all(unix, not(target_os = "android")))]
fn shm_command(name: &str, offset: u32, size: u32) -> KittyGraphicsCommand {
KittyGraphicsCommand {
medium: TransmissionMedium::SharedMemory,
format: Format::Rgba32,
width: 1,
height: 1,
offset,
size,
payload: SmallVec::from_slice(name.as_bytes()),
..Default::default()
}
}
#[cfg(all(unix, not(target_os = "android")))]
#[test]
fn test_shm_unaligned_offset() {
let name = format!("/rio-shm-a-{}", std::process::id());
let cname = std::ffi::CString::new(name.clone()).unwrap();
let mut bytes = vec![0u8; 4096];
bytes[100..104].copy_from_slice(&[1, 2, 3, 4]);
create_shm(&cname, &bytes);
let data = create_graphic_data(&shm_command(&name, 100, 4))
.expect("unaligned offset must load");
assert_eq!(data.pixels, vec![1, 2, 3, 4]);
assert!(!shm_exists(&cname), "shm must be unlinked after read");
}
#[cfg(all(unix, not(target_os = "android")))]
#[test]
fn test_shm_unaligned_offset_past_first_page() {
let name = format!("/rio-shm-d-{}", std::process::id());
let cname = std::ffi::CString::new(name.clone()).unwrap();
let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize;
let offset = page * 2 + 100;
let mut bytes = vec![0u8; page * 3];
bytes[offset..offset + 4].copy_from_slice(&[5, 6, 7, 8]);
create_shm(&cname, &bytes);
let data = create_graphic_data(&shm_command(&name, offset as u32, 4))
.expect("offset past the first page must load");
assert_eq!(data.pixels, vec![5, 6, 7, 8]);
assert!(!shm_exists(&cname));
}
#[cfg(all(unix, not(target_os = "android")))]
#[test]
fn test_shm_range_past_end_is_rejected_and_unlinked() {
let name = format!("/rio-shm-b-{}", std::process::id());
let cname = std::ffi::CString::new(name.clone()).unwrap();
let shm_size = create_shm(&cname, &[0u8; 4096]) as u32;
let result = create_graphic_data(&shm_command(&name, shm_size, 4));
assert!(matches!(result, Err(GraphicError::InvalidData)));
assert!(!shm_exists(&cname), "shm must be unlinked on error");
}
#[cfg(all(unix, not(target_os = "android")))]
#[test]
fn test_shm_offset_without_size_reads_to_end() {
let name = format!("/rio-shm-c-{}", std::process::id());
let cname = std::ffi::CString::new(name.clone()).unwrap();
let shm_size = create_shm(&cname, &[0u8; 4096]) as u32;
let result = create_graphic_data(&shm_command(&name, shm_size - 4, 0));
assert!(result.is_ok(), "{:?}", result.err());
assert!(!shm_exists(&cname));
}
fn file_command(
medium: TransmissionMedium,
path: &std::path::Path,
offset: u32,
size: u32,
) -> KittyGraphicsCommand {
KittyGraphicsCommand {
medium,
format: Format::Rgba32,
width: 1,
height: 1,
offset,
size,
payload: SmallVec::from_slice(path.to_str().unwrap().as_bytes()),
..Default::default()
}
}
fn outside_temp_dir() -> Option<std::path::PathBuf> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../target");
std::fs::create_dir_all(&dir).ok()?;
let dir = std::fs::canonicalize(dir).ok()?;
(!temp_dirs().iter().any(|temp| dir.starts_with(temp))).then_some(dir)
}
struct TestFile(std::path::PathBuf);
impl TestFile {
fn new(dir: &std::path::Path, test: &str, bytes: &[u8]) -> Self {
let path = dir.join(format!(
"rio-{}-tty-graphics-protocol-{}",
test,
std::process::id()
));
std::fs::write(&path, bytes).unwrap();
Self(path)
}
}
impl Drop for TestFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[test]
fn test_file_offset_without_size_reads_to_end() {
let mut bytes = vec![0u8; 54];
bytes.extend_from_slice(&[1, 2, 3, 4]);
let file = TestFile::new(&std::env::temp_dir(), "file-offset", &bytes);
let data =
create_graphic_data(&file_command(TransmissionMedium::File, &file.0, 54, 0))
.expect("offset without size must read to the end");
assert_eq!(data.pixels, vec![1, 2, 3, 4]);
}
#[test]
fn test_file_without_size_ignores_trailing_bytes() {
let file = TestFile::new(
&std::env::temp_dir(),
"file-trailing",
&[1, 2, 3, 4, 9, 9, 9, 9],
);
let data =
create_graphic_data(&file_command(TransmissionMedium::File, &file.0, 0, 0))
.expect("raw pixels without size read width * height * bpp");
assert_eq!(data.pixels, vec![1, 2, 3, 4]);
}
#[test]
fn test_file_offset_past_end_is_rejected() {
let file = TestFile::new(&std::env::temp_dir(), "file-past-end", &[0u8; 4]);
let result =
create_graphic_data(&file_command(TransmissionMedium::File, &file.0, 8, 0));
assert!(matches!(result, Err(GraphicError::InvalidData)));
}
#[test]
fn test_file_oversized_size_is_too_large() {
let file = TestFile::new(&std::env::temp_dir(), "file-too-large", &[0u8; 4]);
let result = create_graphic_data(&file_command(
TransmissionMedium::File,
&file.0,
0,
MAX_SIZE as u32 + 1,
));
assert!(matches!(result, Err(GraphicError::TooLarge)));
}
#[test]
fn test_temp_file_removed_after_failed_read() {
let file = TestFile::new(&std::env::temp_dir(), "temp-failed", &[0u8; 4]);
let result = create_graphic_data(&file_command(
TransmissionMedium::TempFile,
&file.0,
0,
64,
));
assert!(matches!(result, Err(GraphicError::InvalidData)));
assert!(
!file.0.exists(),
"temp file must be removed even on failure"
);
}
#[cfg(not(windows))]
#[test]
fn test_temp_file_outside_temp_dir_is_kept() {
let Some(dir) = outside_temp_dir() else {
return;
};
let file = TestFile::new(&dir, "temp-outside", &[1, 2, 3, 4]);
let result = create_graphic_data(&file_command(
TransmissionMedium::TempFile,
&file.0,
0,
0,
));
assert!(matches!(result, Err(GraphicError::InvalidData)));
assert!(
file.0.exists(),
"files outside temp dirs must never be deleted"
);
}
#[test]
fn test_temp_file_removed_when_too_large() {
let file = TestFile::new(&std::env::temp_dir(), "temp-too-large", &[0u8; 4]);
let result = create_graphic_data(&file_command(
TransmissionMedium::TempFile,
&file.0,
0,
MAX_SIZE as u32 + 1,
));
assert!(matches!(result, Err(GraphicError::TooLarge)));
assert!(!file.0.exists(), "temp file must be removed when too large");
}
#[cfg(unix)]
#[test]
fn test_temp_symlink_removes_link_not_target() {
let target = TestFile::new(&std::env::temp_dir(), "temp-target", &[1, 2, 3, 4]);
let link = TestFile(std::env::temp_dir().join(format!(
"rio-temp-link-tty-graphics-protocol-{}",
std::process::id()
)));
let _ = std::fs::remove_file(&link.0);
std::os::unix::fs::symlink(&target.0, &link.0).unwrap();
let data = create_graphic_data(&file_command(
TransmissionMedium::TempFile,
&link.0,
0,
0,
))
.expect("symlink to a temp file must load");
assert_eq!(data.pixels, vec![1, 2, 3, 4]);
assert!(
std::fs::symlink_metadata(&link.0).is_err(),
"the entry the client named must be removed"
);
assert!(target.0.exists(), "the link target must be kept");
}
#[cfg(unix)]
#[test]
fn test_temp_symlink_to_outside_is_rejected() {
let Some(dir) = outside_temp_dir() else {
return;
};
let target = TestFile::new(&dir, "temp-escape", &[1, 2, 3, 4]);
let link = TestFile(std::env::temp_dir().join(format!(
"rio-temp-escape-tty-graphics-protocol-{}",
std::process::id()
)));
let _ = std::fs::remove_file(&link.0);
std::os::unix::fs::symlink(&target.0, &link.0).unwrap();
let result = create_graphic_data(&file_command(
TransmissionMedium::TempFile,
&link.0,
0,
0,
));
assert!(matches!(result, Err(GraphicError::InvalidData)));
assert!(
target.0.exists(),
"files outside temp dirs must never be deleted"
);
}
#[test]
fn test_temp_file_in_subdirectory_with_marked_dir() {
let dir = std::env::temp_dir().join(format!(
"rio-tty-graphics-protocol-dir-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("frame.rgba");
std::fs::write(&path, [1, 2, 3, 4]).unwrap();
let result =
create_graphic_data(&file_command(TransmissionMedium::TempFile, &path, 0, 0));
let exists = path.exists();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(
result.expect("nested temp file must load").pixels,
vec![1, 2, 3, 4]
);
assert!(!exists, "nested temp file must be removed");
}
#[test]
fn test_file_implied_size_larger_than_file_is_rejected() {
let file = TestFile::new(&std::env::temp_dir(), "file-implied", &[0u8; 4]);
let mut cmd = file_command(TransmissionMedium::File, &file.0, 0, 0);
cmd.width = 10000;
cmd.height = 10000;
let result = create_graphic_data(&cmd);
assert!(matches!(result, Err(GraphicError::InvalidData)));
}
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
#[test]
fn test_opened_path_is_canonical() {
let file = TestFile::new(&std::env::temp_dir(), "opened-path", &[0u8; 4]);
let opened = std::fs::File::open(&file.0).unwrap();
assert_eq!(
opened_path(&opened),
Some(std::fs::canonicalize(&file.0).unwrap())
);
}
#[test]
fn test_is_unsafe_windows_path() {
for path in [
r"\\server\share\tty-graphics-protocol-image.data",
"//server/share/img.rgba",
r"\\?\C:\img.rgba",
r"\\.\pipe\name",
r"\??\C:\img.rgba",
r"C:\Users\me\CON",
r"C:\Users\me\nul.txt",
r"C:\Users\me\com1",
"C:\\Users\\me\\LPT\u{b9}",
r"C:\Users\me\aux:stream",
"CONIN$",
r"C:\x\CONOUT$",
r"C:\x\clock$.txt",
"C:CON",
r"D:nul.txt",
] {
assert!(is_unsafe_windows_path(path), "{path}");
}
for path in [
r"C:\Users\me\img.rgba",
r"C:\Users\me\console.rgba",
r"C:\Users\me\com10",
r"\Users\me\img.rgba",
r"relative\img.rgba",
r"C:img.rgba",
r"C:\x\CONIN$file",
r"C:\src\aux.d\img.png",
r"C:\CON\img.png",
] {
assert!(!is_unsafe_windows_path(path), "{path}");
}
}
#[cfg(unix)]
#[test]
fn test_file_fifo_is_rejected_without_blocking() {
let fifo = TestFile(std::env::temp_dir().join(format!(
"rio-fifo-tty-graphics-protocol-{}",
std::process::id()
)));
let _ = std::fs::remove_file(&fifo.0);
let cpath = std::ffi::CString::new(fifo.0.to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(cpath.as_ptr(), 0o600) }, 0);
let result =
create_graphic_data(&file_command(TransmissionMedium::File, &fifo.0, 0, 0));
assert!(matches!(result, Err(GraphicError::FileNotFound)));
}
#[test]
fn test_is_sensitive_path() {
use std::path::Path;
assert!(is_sensitive_path(Path::new("/proc/self/mem")));
assert!(is_sensitive_path(Path::new("/sys/kernel")));
assert!(is_sensitive_path(Path::new("/dev/zero")));
assert!(!is_sensitive_path(Path::new("/Users/me/Dev/img.png")));
assert!(!is_sensitive_path(Path::new("/home/me/proc/img.png")));
assert!(!is_sensitive_path(Path::new("/devices/img.png")));
assert!(!is_sensitive_path(Path::new("/dev/shm/img.rgba")));
assert!(is_sensitive_path(Path::new("/dev/shm-evil/img.rgba")));
}
#[test]
fn test_is_drive_absolute() {
assert!(is_drive_absolute(r"C:\Users\me\img.rgba"));
assert!(is_drive_absolute(r"d:\tmp\tty-graphics-protocol-x"));
assert!(is_drive_absolute(r"C:\src\aux.d\img.png"));
assert!(!is_drive_absolute(r"UNC\server\share\img.rgba"));
assert!(!is_drive_absolute(r"\\server\share\img.rgba"));
assert!(!is_drive_absolute(r"C:img.rgba"));
assert!(!is_drive_absolute(r"\Users\me\img.rgba"));
}
#[test]
fn test_valid_shm_name() {
assert!(valid_shm_name("/rio-image"));
assert!(valid_shm_name("group.id/rio-image"));
assert!(!valid_shm_name(""));
assert!(!valid_shm_name("/"));
assert!(!valid_shm_name(&"a".repeat(256)));
}
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
#[test]
fn test_remove_temp_file_refuses_swapped_parent() {
let temp = std::fs::canonicalize(std::env::temp_dir()).unwrap();
let real_dir = temp.join(format!("rio-swap-real-{}", std::process::id()));
let link_dir = temp.join(format!("rio-swap-link-{}", std::process::id()));
let _ = std::fs::remove_file(&link_dir);
std::fs::create_dir_all(&real_dir).unwrap();
std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
let file = real_dir.join("tty-graphics-protocol-x");
std::fs::write(&file, [0u8; 4]).unwrap();
remove_temp_file(
std::fs::File::open(&file).unwrap(),
&link_dir.join("tty-graphics-protocol-x"),
);
let kept = file.exists();
remove_temp_file(std::fs::File::open(&file).unwrap(), &file);
let removed = !file.exists();
let _ = std::fs::remove_file(&link_dir);
let _ = std::fs::remove_dir_all(&real_dir);
assert!(kept, "a parent that resolves elsewhere must not be used");
assert!(removed, "the validated path must be removed");
}
#[test]
fn test_raw_without_dimensions_is_rejected_before_reading() {
let file = TestFile::new(&std::env::temp_dir(), "no-dims", &[0u8; 4]);
let mut cmd = file_command(TransmissionMedium::File, &file.0, 0, 0);
cmd.width = 0;
cmd.height = 0;
let result = create_graphic_data(&cmd);
assert!(matches!(result, Err(GraphicError::DimensionsRequired)));
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn test_proc_self_fd_is_rejected() {
let file = TestFile::new(&std::env::temp_dir(), "proc-fd", &[1, 2, 3, 4]);
let held = std::fs::File::open(&file.0).unwrap();
let fd_path = std::path::PathBuf::from(format!(
"/proc/self/fd/{}",
std::os::unix::io::AsRawFd::as_raw_fd(&held)
));
let live =
create_graphic_data(&file_command(TransmissionMedium::File, &fd_path, 0, 0));
assert!(
live.is_err(),
"live files behind /proc/self/fd must not load"
);
std::fs::remove_file(&file.0).unwrap();
let deleted =
create_graphic_data(&file_command(TransmissionMedium::File, &fd_path, 0, 0));
assert!(
deleted.is_err(),
"deleted files behind /proc/self/fd must not load"
);
}
#[test]
fn test_temp_file_removed_without_dimensions() {
let file = TestFile::new(&std::env::temp_dir(), "temp-no-dims", &[0u8; 4]);
let mut cmd = file_command(TransmissionMedium::TempFile, &file.0, 0, 0);
cmd.width = 0;
cmd.height = 0;
let result = create_graphic_data(&cmd);
assert!(matches!(result, Err(GraphicError::DimensionsRequired)));
assert!(
!file.0.exists(),
"temp file must be removed on dimension errors"
);
}
#[cfg(all(unix, not(target_os = "android")))]
#[test]
fn test_shm_unlinked_without_dimensions() {
let name = format!("/rio-shm-e-{}", std::process::id());
let cname = std::ffi::CString::new(name.clone()).unwrap();
create_shm(&cname, &[0u8; 4096]);
let mut cmd = shm_command(&name, 0, 0);
cmd.width = 0;
cmd.height = 0;
let result = create_graphic_data(&cmd);
assert!(matches!(result, Err(GraphicError::DimensionsRequired)));
assert!(
!shm_exists(&cname),
"shm must be unlinked on dimension errors"
);
}
}