Files
ghostty-rs/crates/ghostty-terminal/src/kitty/graphics_image.rs
T
ddidderr a8d0d624da test(windows): run the Windows code under Wine; theme preview parity
Windows was previously only compile-checked. With Wine 11 (staging)
and mingw available, scripts/windows-wine-test.sh (`just
test-windows-wine`) builds expat/FreeType/fontconfig for mingw under
target/wine-deps and runs the test suites of ghostty-vt, unicode,
datastruct, input, os, config, terminal, crash, termio and cli under
Wine (~2,700 tests). New termio/tests/exec_windows.rs drives real
cmd.exe children over ConPTY through Termio (output, exit codes,
typing, 120 KiB writes, stop, start failure). Fixes found: Windows
paths from fs::canonicalize leaked the \\?\ prefix (new
ghostty_os::path::realpath strips it like Zig); ghostty-font compiles
hb-ft for bundled HarfBuzz on Windows; ~25 tests had Unix assumptions.
Wine limitations are ignored with reasons (ResizePseudoConsole stub,
no POSIX rename over existing files) - docs/rewrite/notes/windows-wine.md.

+list-themes preview parity (former KNOWN_DIFFERENCES rows 5-9): the
search box ports vaxis TextInput (word navigation and editing bindings,
grapheme-wise editing), ASCII-only case folding, light initial colors
until terminal reports arrive (no COLORFGBG), and load errors end the
preview with exit 1; PTY tests compare with /usr/bin/ghostty.

Test Plan:
- just test-windows-wine
- cargo test -p ghostty-cli --test list_themes_pty
- cargo test --workspace --no-fail-fast (3601 passed)
2026-10-12 17:23:24 +02:00

1130 lines
37 KiB
Rust

//! Kitty graphics image loading (`ZIG/src/terminal/kitty/graphics_image.zig`).
//!
//! [`LoadingImage`] is an image that is still being transmitted (possibly in
//! chunks). [`Image`] is a fully loaded and validated image.
#[cfg(unix)]
use std::os::unix::{ffi::OsStrExt, fs::FileExt};
use std::{
io::{Cursor, Read, Seek, SeekFrom},
path::Path,
time::Instant,
};
/// wasm32 has no `std::os::unix`; on Windows `as_encoded_bytes` is WTF-8.
#[cfg(not(unix))]
trait OsStrExt {
fn as_bytes(&self) -> &[u8];
}
#[cfg(not(unix))]
impl OsStrExt for std::ffi::OsStr {
fn as_bytes(&self) -> &[u8] {
self.as_encoded_bytes()
}
}
/// The OS path named by protocol payload bytes. Unix paths are the bytes;
/// on Windows they are WTF-8 (lone surrogates are valid, malformed input is
/// not a path); elsewhere (wasm) they must be UTF-8.
#[cfg(unix)]
#[allow(clippy::unnecessary_wraps)] // same signature as the fallible targets
fn os_path_from_bytes(bytes: &[u8]) -> Option<std::ffi::OsString> {
Some(std::ffi::OsStr::from_bytes(bytes).to_owned())
}
#[cfg(windows)]
fn os_path_from_bytes(bytes: &[u8]) -> Option<std::ffi::OsString> {
ghostty_os::osbytes::EncodedOs::from_bytes(bytes)
.try_to_os()
.ok()
}
#[cfg(not(any(unix, windows)))]
fn os_path_from_bytes(bytes: &[u8]) -> Option<std::ffi::OsString> {
std::str::from_utf8(bytes).ok().map(Into::into)
}
/// `posix.realpath` (no `\\?\` verbatim prefix on Windows, like Zig).
#[cfg(windows)]
fn realpath(path: impl AsRef<Path>) -> std::io::Result<std::path::PathBuf> {
ghostty_os::path::realpath(path)
}
#[cfg(not(windows))]
fn realpath(path: impl AsRef<Path>) -> std::io::Result<std::path::PathBuf> {
std::fs::canonicalize(path)
}
use ghostty_vt::kitty::graphics_command::{
Command,
Display,
Quiet,
TransmissionCompression,
TransmissionFormat,
TransmissionMedium,
};
use crate::page_list::Pin;
/// Maximum width or height of an image. Taken directly from Kitty.
pub const MAX_DIMENSION: u32 = 10000;
/// Maximum size in bytes, taken from Kitty.
pub const MAX_SIZE: usize = 400 * 1024 * 1024; // 400MB
/// Errors that can occur while loading an image (`Image.Error` plus
/// `OutOfMemory` in Zig).
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error("out of memory")]
OutOfMemory,
#[error("internal error")]
InternalError,
#[error("invalid data")]
InvalidData,
#[error("decompression failed")]
DecompressionFailed,
#[error("dimensions required")]
DimensionsRequired,
#[error("dimensions too large")]
DimensionsTooLarge,
#[error("file path too long")]
FilePathTooLong,
#[error("temporary file not in temp dir")]
TemporaryFileNotInTempDir,
#[error("temporary file not named correctly")]
TemporaryFileNotNamedCorrectly,
#[error("unsupported format")]
UnsupportedFormat,
#[error("unsupported medium")]
UnsupportedMedium,
#[error("unsupported pixel depth")]
UnsupportedDepth,
}
/// An image that is still being loaded. The image should be initialized
/// using [`LoadingImage::init`] on the first chunk and then `add_data` for
/// each subsequent chunk. Once all chunks have been added, `complete`
/// should be called to finalize the image.
#[derive(Debug)]
pub struct LoadingImage {
/// The in-progress image. The first chunk must have all the metadata
/// so this comes from that initially.
pub image: Image,
/// The data that is being built up.
pub data: Vec<u8>,
/// This is non-null when a transmit and display command is given
/// so that we display the image after it is fully loaded.
pub display: Option<Display>,
/// Quiet is the quiet settings for the initial load command. This is
/// used if q isn't set on subsequent chunks.
pub quiet: Quiet,
}
impl LoadingImage {
/// Initialize a chunked image from the first image transmission.
/// If this is a multi-chunk image, this should only be the FIRST
/// chunk.
pub fn init(cmd: &Command) -> Result<LoadingImage, Error> {
// Build our initial image from the properties sent via the control.
// These can be overwritten by the data loading process. For example,
// PNG loading sets the width/height from the data.
let t = cmd.transmission().ok_or(Error::InternalError)?;
let mut result = LoadingImage {
image: Image {
id: t.image_id,
number: t.image_number,
width: t.width,
height: t.height,
compression: t.compression,
format: t.format,
..Image::default()
},
data: Vec::new(),
display: cmd.display(),
quiet: cmd.quiet,
};
// Special case for the direct medium, we just add the chunk directly.
if t.medium == TransmissionMedium::Direct {
result.add_data(&cmd.data)?;
return Ok(result);
}
// Otherwise, the payload data is guaranteed to be a path.
if cmd.data.contains(&0) {
log::warn!("failed to get absolute path: BadPathName");
return Err(Error::InvalidData);
}
match t.medium {
TransmissionMedium::Direct => unreachable!("handled above"),
TransmissionMedium::File | TransmissionMedium::TemporaryFile => {
let Some(os_path) = os_path_from_bytes(&cmd.data) else {
log::warn!("failed to get absolute path: BadPathName");
return Err(Error::InvalidData);
};
let path = match realpath(Path::new(&os_path)) {
Ok(p) => p,
Err(err) => {
log::warn!("failed to get absolute path: {err}");
return Err(Error::InvalidData);
}
};
result.read_file(
t.medium == TransmissionMedium::TemporaryFile,
t.offset,
t.size,
path.as_os_str().as_bytes(),
)?;
}
TransmissionMedium::SharedMemory => {
result.read_shared_memory(t.offset, t.size, &cmd.data)?;
}
}
Ok(result)
}
/// Reads the data from a shared memory segment.
#[cfg(target_arch = "wasm32")]
#[allow(clippy::unused_self)]
fn read_shared_memory(&mut self, _offset: u32, _size: u32, path: &[u8]) -> Result<(), Error> {
// POSIX shared memory does not exist on wasm32.
log::warn!(
"shared memory not supported on wasm: {}",
String::from_utf8_lossy(path)
);
Err(Error::InvalidData)
}
/// Reads the data from a shared memory segment.
///
/// Windows is currently unsupported (as in Zig).
#[cfg(windows)]
#[allow(clippy::unused_self)]
fn read_shared_memory(&mut self, _offset: u32, _size: u32, _path: &[u8]) -> Result<(), Error> {
Err(Error::UnsupportedMedium)
}
/// Reads the data from a shared memory segment.
#[cfg(not(any(target_arch = "wasm32", windows)))]
fn read_shared_memory(&mut self, offset: u32, size: u32, path: &[u8]) -> Result<(), Error> {
use rustix::{fs::Mode, shm};
let path_str = String::from_utf8_lossy(path);
let fd = match shm::open(path, shm::OFlags::RDONLY, Mode::empty()) {
Ok(fd) => fd,
Err(err) => {
log::warn!("unable to open shared memory {path_str}: {err}");
return Err(Error::InvalidData);
}
};
// The segment is always unlinked once we're done (even on error).
let result = self.read_shm_fd(fd, offset, size, &path_str);
let _ = shm::unlink(path);
result
}
#[cfg(not(any(target_arch = "wasm32", windows)))]
fn read_shm_fd(
&mut self,
fd: rustix::fd::OwnedFd,
offset: u32,
size: u32,
path: &str,
) -> Result<(), Error> {
let file = std::fs::File::from(fd);
// The size from stat on may be larger than our expected size because
// shared memory has to be a multiple of the page size.
let stat_size: usize = match file.metadata() {
Ok(m) => {
if m.len() == 0 {
return Err(Error::InvalidData);
}
m.len() as usize
}
Err(err) => {
log::warn!("unable to fstat shared memory {path}: {err}");
return Err(Error::InvalidData);
}
};
let expected_size: usize = match self.image.format {
// Png we decode the full data size because later decoding will
// get the proper dimensions and assert validity.
TransmissionFormat::Png => stat_size,
// For these formats we have a size we must have.
f => {
let bpp = usize::from(f.bpp());
(self.image.width as usize)
.saturating_mul(self.image.height as usize)
.saturating_mul(bpp)
}
};
// Our stat size must be at least the expected size otherwise
// the shared memory data is invalid.
if stat_size < expected_size {
log::warn!("shared memory size too small expected={expected_size} actual={stat_size}");
return Err(Error::InvalidData);
}
// Our end size always uses the expected size so we cut off the
// padding for mmap alignment.
let start = offset as usize;
let end = if size > 0 {
(start + size as usize).min(expected_size)
} else {
expected_size
};
if start > end {
return Err(Error::InvalidData);
}
debug_assert_eq!(self.data.len(), 0);
let mut buf = vec![0u8; end - start];
if let Err(err) = file.read_exact_at(&mut buf, start as u64) {
log::warn!("unable to read shared memory {path}: {err}");
return Err(Error::InvalidData);
}
self.data = buf;
Ok(())
}
/// Reads the data from a (temporary) file.
///
/// This will also delete the temporary file if it is in a safe location.
fn read_file(
&mut self,
temporary: bool,
offset: u32,
size: u32,
path: &[u8],
) -> Result<(), Error> {
// Verify file seems "safe". This is logic copied directly from Kitty,
// mostly. This is really rough but it will catch obvious bad actors.
if path.starts_with(b"/proc/")
|| path.starts_with(b"/sys/")
|| (path.starts_with(b"/dev/") && !path.starts_with(b"/dev/shm/"))
{
return Err(Error::InvalidData);
}
// Temporary file logic
if temporary {
if !is_path_in_temp_dir(path) {
return Err(Error::TemporaryFileNotInTempDir);
}
if !contains_subslice(path, b"tty-graphics-protocol") {
return Err(Error::TemporaryFileNotNamedCorrectly);
}
}
let Some(os_path) = os_path_from_bytes(path) else {
return Err(Error::InvalidData);
};
let os_path = Path::new(&os_path);
let result = self.read_file_inner(os_path, offset, size);
if temporary && let Err(err) = std::fs::remove_file(os_path) {
log::warn!("failed to delete temporary file: {err}");
}
result
}
fn read_file_inner(&mut self, path: &Path, offset: u32, size: u32) -> Result<(), Error> {
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(err) => {
log::warn!("failed to open temporary file: {err}");
return Err(Error::InvalidData);
}
};
// File must be a regular file
match file.metadata() {
Ok(m) => {
if !m.is_file() {
log::warn!("file is not a regular file");
return Err(Error::InvalidData);
}
}
Err(err) => {
log::warn!("failed to stat file: {err}");
return Err(Error::InvalidData);
}
}
if offset > 0
&& let Err(err) = file.seek(SeekFrom::Start(u64::from(offset)))
{
log::warn!("failed to seek to offset {offset}: {err}");
return Err(Error::InvalidData);
}
// Read the file
let limit = if size > 0 {
(size as usize).min(MAX_SIZE)
} else {
MAX_SIZE
};
let mut buf = Vec::new();
if let Err(err) = file.take(limit as u64).read_to_end(&mut buf) {
log::warn!("failed to read temporary file: {err}");
return Err(Error::InvalidData);
}
debug_assert_eq!(self.data.len(), 0);
self.data = buf;
Ok(())
}
/// The file name `debug_dump` writes (Zig `@tagName` spellings).
#[cfg(debug_assertions)]
#[must_use]
pub fn debug_dump_filename(&self) -> String {
let format = match self.image.format {
TransmissionFormat::Rgb => "rgb",
TransmissionFormat::Rgba => "rgba",
TransmissionFormat::Png => "png",
TransmissionFormat::GrayAlpha => "gray_alpha",
TransmissionFormat::Gray => "gray",
};
let compression = match self.image.compression {
TransmissionCompression::None => "none",
TransmissionCompression::ZlibDeflate => "zlib_deflate",
};
format!(
"image-{format}-{compression}-{}x{}-{}.data",
self.image.width, self.image.height, self.image.id
)
}
/// Debug function to write the data to a file in `dir`. This is useful
/// for capturing some test data for unit tests. Only exists in debug
/// builds (Zig: `@compileError("debugDump in non-debug")`).
#[cfg(debug_assertions)]
pub fn debug_dump_to(&self, dir: &std::path::Path) -> std::io::Result<()> {
std::fs::write(dir.join(self.debug_dump_filename()), &self.data)
}
/// Same as [`Self::debug_dump_to`] using the current working directory,
/// like Zig's `debugDump`.
#[cfg(debug_assertions)]
pub fn debug_dump(&self) -> std::io::Result<()> {
self.debug_dump_to(std::path::Path::new("."))
}
/// Adds a chunk of data to the image. Use this if the image
/// is coming in chunks (the "m" parameter in the protocol).
pub fn add_data(&mut self, data: &[u8]) -> Result<(), Error> {
// If no data, skip
if data.is_empty() {
return Ok(());
}
// If our data would get too big, return an error
if self.data.len() + data.len() > MAX_SIZE {
log::warn!("image data too large max_size={MAX_SIZE}");
return Err(Error::InvalidData);
}
self.data.extend_from_slice(data);
Ok(())
}
/// Complete the chunked image, returning a completed image.
pub fn complete(&mut self) -> Result<Image, Error> {
// Decompress the data if it is compressed.
self.decompress()?;
// Decode the png if we have to
if self.image.format == TransmissionFormat::Png {
self.decode_png()?;
}
let img = &self.image;
// Validate our dimensions.
if img.width == 0 || img.height == 0 {
return Err(Error::DimensionsRequired);
}
if img.width > MAX_DIMENSION || img.height > MAX_DIMENSION {
return Err(Error::DimensionsTooLarge);
}
// Data length must be what we expect
let bpp = usize::from(img.format.bpp());
let expected_len = img.width as usize * img.height as usize * bpp;
let actual_len = self.data.len();
if actual_len != expected_len {
log::warn!(
"unexpected length image id={} width={} height={} bpp={} expected_len={} actual_len={}",
img.id,
img.width,
img.height,
bpp,
expected_len,
actual_len
);
return Err(Error::InvalidData);
}
// Set our time
self.image.transmit_time = Instant::now();
// Everything looks good, copy the image data over.
let mut result = std::mem::take(&mut self.image);
result.data = std::mem::take(&mut self.data);
Ok(result)
}
/// Decompress the data in-place.
fn decompress(&mut self) -> Result<(), Error> {
match self.image.compression {
TransmissionCompression::None => Ok(()),
TransmissionCompression::ZlibDeflate => self.decompress_zlib(),
}
}
fn decompress_zlib(&mut self) -> Result<(), Error> {
let decoder = flate2::read::ZlibDecoder::new(self.data.as_slice());
let mut list = Vec::new();
// Read one byte past the limit so that an overlong stream is an
// error (Zig: `.limited(max_size)` + StreamTooLong).
if let Err(err) = decoder.take(MAX_SIZE as u64 + 1).read_to_end(&mut list) {
log::warn!("failed to read decompressed data: {err}");
return Err(Error::DecompressionFailed);
}
if list.len() > MAX_SIZE {
log::warn!("failed to read decompressed data: StreamTooLong");
return Err(Error::DecompressionFailed);
}
self.data = list;
// Make sure we note that our image is no longer compressed
self.image.compression = TransmissionCompression::None;
Ok(())
}
/// Decode the data as PNG. This will also update the image dimensions.
fn decode_png(&mut self) -> Result<(), Error> {
debug_assert_eq!(self.image.format, TransmissionFormat::Png);
let (width, height, data) = decode_png_rgba(&self.data)?;
if data.len() > MAX_SIZE {
log::warn!(
"png image too large size={} max_size={MAX_SIZE}",
data.len()
);
return Err(Error::InvalidData);
}
// Replace our data
self.data = data;
// Store updated image dimensions
self.image.width = width;
self.image.height = height;
self.image.format = TransmissionFormat::Rgba;
Ok(())
}
}
/// Decode a PNG into 8-bit non-premultiplied RGBA (what wuffs produces).
fn decode_png_rgba(data: &[u8]) -> Result<(u32, u32, Vec<u8>), Error> {
let mut decoder = png::Decoder::new(Cursor::new(data));
decoder.set_limits(png::Limits { bytes: MAX_SIZE });
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().map_err(|_| Error::InvalidData)?;
let size = reader.output_buffer_size().ok_or(Error::InvalidData)?;
if size > MAX_SIZE {
return Err(Error::InvalidData);
}
let mut buf = vec![0u8; size];
let info = reader
.next_frame(&mut buf)
.map_err(|_| Error::InvalidData)?;
if info.bit_depth != png::BitDepth::Eight {
return Err(Error::InvalidData);
}
let px = (info.width as usize)
.checked_mul(info.height as usize)
.ok_or(Error::InvalidData)?;
let src = &buf[..info.buffer_size()];
let mut out = Vec::with_capacity(px * 4);
match info.color_type {
png::ColorType::Rgba => out.extend_from_slice(src),
png::ColorType::Rgb => {
for c in src.as_chunks::<3>().0 {
out.extend_from_slice(&[c[0], c[1], c[2], 255]);
}
}
png::ColorType::Grayscale => {
for &g in src {
out.extend_from_slice(&[g, g, g, 255]);
}
}
png::ColorType::GrayscaleAlpha => {
for c in src.as_chunks::<2>().0 {
out.extend_from_slice(&[c[0], c[0], c[0], c[1]]);
}
}
png::ColorType::Indexed => return Err(Error::InvalidData),
}
if out.len() != px * 4 {
return Err(Error::InvalidData);
}
Ok((info.width, info.height, out))
}
fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
hay.windows(needle.len()).any(|w| w == needle)
}
/// Returns true if path appears to be in a temporary directory.
/// Copies logic from Kitty.
fn is_path_in_temp_dir(path: &[u8]) -> bool {
is_path_in_temp_dir_with(path, alloc_tmp_dir())
}
/// [`is_path_in_temp_dir`] with an explicit temporary directory (an OS
/// string: it may be non-UTF-8, so it is compared byte for byte).
fn is_path_in_temp_dir_with(path: &[u8], tmp_dir: Option<std::ffi::OsString>) -> bool {
if path.starts_with(b"/tmp") {
return true;
}
if path.starts_with(b"/dev/shm") {
return true;
}
if let Some(dir) = tmp_dir {
if path.starts_with(dir.as_os_str().as_bytes()) {
return true;
}
// The temporary dir is sometimes a symlink. On macOS for
// example /tmp is /private/var/...
if let Ok(real_dir) = realpath(&dir)
&& path.starts_with(real_dir.as_os_str().as_bytes())
{
return true;
}
}
false
}
/// `os/file.zig allocTmpDir` on POSIX (TMPDIR, TMP, then /tmp), as the raw
/// OS string.
#[cfg(not(windows))]
#[allow(clippy::unnecessary_wraps)]
fn alloc_tmp_dir() -> Option<std::ffi::OsString> {
for key in ["TMPDIR", "TMP"] {
if let Some(v) = std::env::var_os(key) {
return Some(v);
}
}
Some("/tmp".into())
}
/// `os/file.zig allocTmpDir` on Windows (`TMP` only).
#[cfg(windows)]
fn alloc_tmp_dir() -> Option<std::ffi::OsString> {
std::env::var_os("TMP")
}
/// Image represents a single fully loaded image.
#[derive(Debug, Clone)]
pub struct Image {
pub id: u32,
pub number: u32,
pub width: u32,
pub height: u32,
pub format: TransmissionFormat,
pub compression: TransmissionCompression,
pub data: Vec<u8>,
pub transmit_time: Instant,
/// Set this to true if this image was loaded by a command that
/// doesn't specify an ID or number, since such commands should
/// not be responded to, even though we do currently give them
/// IDs in the public range (which is bad!).
pub implicit_id: bool,
}
impl Default for Image {
fn default() -> Self {
Image {
id: 0,
number: 0,
width: 0,
height: 0,
format: TransmissionFormat::Rgb,
compression: TransmissionCompression::None,
data: Vec::new(),
transmit_time: Instant::now(),
implicit_id: false,
}
}
}
impl Image {
/// Mostly for logging
pub fn without_data(&self) -> Image {
let mut copy = self.clone();
copy.data = Vec::new();
copy
}
}
/// The rect taken up by some image placement, in grid cells. This will
/// be rounded up to the nearest grid cell since we can't place images
/// in partial grid cells.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rect {
pub top_left: Pin,
pub bottom_right: Pin,
}
#[cfg(test)]
mod tests {
#[test]
fn payload_path_bytes_are_kept() {
// Unix keeps arbitrary bytes; Windows accepts WTF-8 (lone
// surrogates) and rejects malformed input instead of using an
// empty path.
#[cfg(unix)]
{
let p = super::os_path_from_bytes(b"/tmp/\xff-tty-graphics-protocol").unwrap();
assert_eq!(p.as_encoded_bytes(), b"/tmp/\xff-tty-graphics-protocol");
}
#[cfg(windows)]
{
assert!(super::os_path_from_bytes(b"C:\\\xed\xa0\x80").is_some());
assert!(super::os_path_from_bytes(b"C:\\\xff").is_none());
}
assert!(super::os_path_from_bytes(b"plain").is_some());
}
#[cfg(unix)]
#[test]
fn temp_dir_check_keeps_non_utf8_tmpdir_bytes() {
use std::os::unix::ffi::OsStringExt;
let dir = std::ffi::OsString::from_vec(b"/data/t\xffmp".to_vec());
assert!(is_path_in_temp_dir_with(
b"/data/t\xffmp/tty-graphics-protocol-x",
Some(dir.clone())
));
// A lossy conversion would have turned the byte into U+FFFD.
assert!(!is_path_in_temp_dir_with(
b"/data/t\xef\xbf\xbdmp/tty-graphics-protocol-x",
Some(dir)
));
assert!(!is_path_in_temp_dir_with(b"/data/x", None));
}
use ghostty_vt::kitty::graphics_command::{Control, Transmission};
use super::*;
#[cfg(debug_assertions)]
#[test]
fn loading_image_debug_dump_writes_data() {
let mut li = LoadingImage {
image: Image {
id: 7,
width: 3,
height: 2,
format: TransmissionFormat::GrayAlpha,
compression: TransmissionCompression::ZlibDeflate,
..Image::default()
},
data: vec![1, 2, 3, 4],
display: None,
quiet: Quiet::default(),
};
assert_eq!(
"image-gray_alpha-zlib_deflate-3x2-7.data",
li.debug_dump_filename()
);
let dir = std::env::temp_dir().join(format!("ghostty-dump-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
li.debug_dump_to(&dir).unwrap();
let got = std::fs::read(dir.join(li.debug_dump_filename())).unwrap();
assert_eq!(vec![1, 2, 3, 4], got);
li.image.format = TransmissionFormat::Png;
li.image.compression = TransmissionCompression::None;
assert_eq!("image-png-none-3x2-7.data", li.debug_dump_filename());
std::fs::remove_dir_all(&dir).unwrap();
}
const RGB_NONE_20X15: &[u8] =
include_bytes!("testdata/image-rgb-none-20x15-2147483647-raw.data");
const RGB_ZLIB_128X96: &[u8] =
include_bytes!("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data");
const PNG_50X76: &[u8] = include_bytes!("testdata/image-png-none-50x76-2147483647-raw.data");
fn cmd(t: Transmission, data: &[u8]) -> Command {
Command {
control: Control::Transmit(t),
quiet: Quiet::No,
data: data.to_vec(),
}
}
/// Unique temporary directory removed on drop.
struct TmpDir(std::path::PathBuf);
impl TmpDir {
fn new(name: &str) -> TmpDir {
let p =
std::env::temp_dir().join(format!("ghostty-kitty-{}-{name}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
TmpDir(p)
}
fn write(&self, name: &str, data: &[u8]) -> String {
let p = self.0.join(name);
std::fs::write(&p, data).unwrap();
realpath(p).unwrap().to_str().unwrap().to_owned()
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
// This specifically tests we ALLOW invalid RGB data because Kitty
// documents that this should work.
#[test]
fn image_load_with_invalid_rgb_data() {
// <ESC>_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA<ESC>\
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
width: 1,
height: 1,
image_id: 31,
..Transmission::default()
},
b"AAAA",
);
let _loading = LoadingImage::init(&c).unwrap();
}
#[test]
fn image_load_with_image_too_wide() {
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
width: MAX_DIMENSION + 1,
height: 1,
image_id: 31,
..Transmission::default()
},
b"AAAA",
);
let mut loading = LoadingImage::init(&c).unwrap();
assert_eq!(loading.complete().unwrap_err(), Error::DimensionsTooLarge);
}
#[test]
fn image_load_with_image_too_tall() {
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
height: MAX_DIMENSION + 1,
width: 1,
image_id: 31,
..Transmission::default()
},
b"AAAA",
);
let mut loading = LoadingImage::init(&c).unwrap();
assert_eq!(loading.complete().unwrap_err(), Error::DimensionsTooLarge);
}
#[test]
fn image_load_rgb_zlib_compressed_direct() {
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::Direct,
compression: TransmissionCompression::ZlibDeflate,
height: 96,
width: 128,
image_id: 31,
..Transmission::default()
},
RGB_ZLIB_128X96,
);
let mut loading = LoadingImage::init(&c).unwrap();
let img = loading.complete().unwrap();
// should be decompressed
assert_eq!(img.compression, TransmissionCompression::None);
assert_eq!(img.data.len(), 128 * 96 * 3);
}
#[test]
fn image_load_rgb_not_compressed_direct() {
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::Direct,
compression: TransmissionCompression::None,
width: 20,
height: 15,
image_id: 31,
..Transmission::default()
},
RGB_NONE_20X15,
);
let mut loading = LoadingImage::init(&c).unwrap();
let img = loading.complete().unwrap();
assert_eq!(img.compression, TransmissionCompression::None);
}
#[test]
fn image_load_rgb_zlib_compressed_direct_chunked() {
let data = RGB_ZLIB_128X96;
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::Direct,
compression: TransmissionCompression::ZlibDeflate,
height: 96,
width: 128,
image_id: 31,
more_chunks: true,
..Transmission::default()
},
&data[0..1024],
);
let mut loading = LoadingImage::init(&c).unwrap();
// Read our remaining chunks
for chunk in data[1024..].chunks(1024) {
loading.add_data(chunk).unwrap();
}
let img = loading.complete().unwrap();
assert_eq!(img.compression, TransmissionCompression::None);
}
#[test]
fn image_load_rgb_zlib_compressed_direct_chunked_with_zero_initial_chunk() {
let data = RGB_ZLIB_128X96;
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::Direct,
compression: TransmissionCompression::ZlibDeflate,
height: 96,
width: 128,
image_id: 31,
more_chunks: true,
..Transmission::default()
},
b"",
);
let mut loading = LoadingImage::init(&c).unwrap();
for chunk in data.chunks(1024) {
loading.add_data(chunk).unwrap();
}
let img = loading.complete().unwrap();
assert_eq!(img.compression, TransmissionCompression::None);
}
#[test]
fn image_load_temporary_file_without_correct_path() {
let tmp = TmpDir::new("nocorrectpath");
let path = tmp.write("image.data", RGB_NONE_20X15);
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::TemporaryFile,
compression: TransmissionCompression::None,
width: 20,
height: 15,
image_id: 31,
..Transmission::default()
},
path.as_bytes(),
);
assert_eq!(
LoadingImage::init(&c).unwrap_err(),
Error::TemporaryFileNotNamedCorrectly
);
// Temporary file should still be there
assert!(Path::new(&path).exists());
}
#[test]
fn image_load_rgb_not_compressed_temporary_file() {
let tmp = TmpDir::new("tempfile");
let path = tmp.write("tty-graphics-protocol-image.data", RGB_NONE_20X15);
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::TemporaryFile,
compression: TransmissionCompression::None,
width: 20,
height: 15,
image_id: 31,
..Transmission::default()
},
path.as_bytes(),
);
let mut loading = LoadingImage::init(&c).unwrap();
let img = loading.complete().unwrap();
assert_eq!(img.compression, TransmissionCompression::None);
// Temporary file should be gone
assert!(!Path::new(&path).exists());
}
#[test]
fn image_load_rgb_not_compressed_regular_file() {
let tmp = TmpDir::new("regularfile");
let path = tmp.write("image.data", RGB_NONE_20X15);
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::File,
compression: TransmissionCompression::None,
width: 20,
height: 15,
image_id: 31,
..Transmission::default()
},
path.as_bytes(),
);
let mut loading = LoadingImage::init(&c).unwrap();
let img = loading.complete().unwrap();
assert_eq!(img.compression, TransmissionCompression::None);
assert!(Path::new(&path).exists());
}
#[test]
fn image_load_png_not_compressed_regular_file() {
let tmp = TmpDir::new("pngfile");
let path = tmp.write("tty-graphics-protocol-image.data", PNG_50X76);
let c = cmd(
Transmission {
format: TransmissionFormat::Png,
medium: TransmissionMedium::File,
compression: TransmissionCompression::None,
width: 0,
height: 0,
image_id: 31,
..Transmission::default()
},
path.as_bytes(),
);
let mut loading = LoadingImage::init(&c).unwrap();
let img = loading.complete().unwrap();
assert_eq!(img.compression, TransmissionCompression::None);
assert_eq!(img.format, TransmissionFormat::Rgba);
assert_eq!((img.width, img.height), (50, 76));
assert_eq!(img.data.len(), 50 * 76 * 4);
assert!(Path::new(&path).exists());
}
#[test]
fn image_load_rejects_proc_and_nul_paths() {
// The `/proc/` guard is a prefix check on the canonical path, which
// on Windows is a drive path (Wine even maps `/proc` through `Z:`),
// so it only applies to POSIX.
#[cfg(unix)]
{
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::File,
width: 1,
height: 1,
..Transmission::default()
},
b"/proc/self/status",
);
assert_eq!(LoadingImage::init(&c).unwrap_err(), Error::InvalidData);
}
let c = cmd(
Transmission {
medium: TransmissionMedium::File,
..Transmission::default()
},
b"/tmp/a\0b",
);
assert_eq!(LoadingImage::init(&c).unwrap_err(), Error::InvalidData);
}
#[test]
fn image_load_file_offset_and_size() {
let tmp = TmpDir::new("offsetsize");
let mut data = vec![0xAAu8; 5];
data.extend_from_slice(&[1, 2, 3, 4, 5, 6]);
data.extend_from_slice(&[0xBB; 7]);
let path = tmp.write("image.data", &data);
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::File,
width: 2,
height: 1,
offset: 5,
size: 6,
..Transmission::default()
},
path.as_bytes(),
);
let mut loading = LoadingImage::init(&c).unwrap();
assert_eq!(loading.data, vec![1, 2, 3, 4, 5, 6]);
assert!(loading.complete().is_ok());
}
#[cfg(not(any(target_arch = "wasm32", windows)))]
#[test]
fn image_load_shared_memory() {
use std::io::Write;
use rustix::{fs::Mode, shm};
let name = format!("/ghostty-kitty-test-{}", std::process::id());
let fd = shm::open(
name.as_str(),
shm::OFlags::CREATE | shm::OFlags::EXCL | shm::OFlags::RDWR,
Mode::RUSR | Mode::WUSR,
)
.unwrap();
let mut f = std::fs::File::from(fd);
f.write_all(&[7u8; 20 * 15 * 3]).unwrap();
drop(f);
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::SharedMemory,
width: 20,
height: 15,
..Transmission::default()
},
name.as_bytes(),
);
let mut loading = LoadingImage::init(&c).unwrap();
let img = loading.complete().unwrap();
assert_eq!(img.data.len(), 900);
assert!(img.data.iter().all(|&b| b == 7));
// The segment is unlinked after loading.
assert!(shm::open(name.as_str(), shm::OFlags::RDONLY, Mode::empty()).is_err());
}
}