From 87ab3e98f4d399337777e4329960d7cd09101e99 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 14 Sep 2026 21:03:15 +0530 Subject: [PATCH] Graphics protocol: Fix file descriptor leak that can be triggered by malicious clients Also make failure responses generic to avoid leaking any filesystem information to failed opens --- docs/changelog.rst | 4 + docs/graphics-protocol.rst | 12 +++ kitty/boss.py | 4 + kitty/graphics.c | 168 +++++++++++++++++++++++++++++-------- kitty/utils.py | 35 ++++++-- kitty_tests/datatypes.py | 7 ++ kitty_tests/graphics.py | 110 ++++++++++++++++++++---- 7 files changed, 284 insertions(+), 56 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f19e5af15..fd95f61e3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -302,6 +302,10 @@ Detailed list of changes object at an offset (the ``O`` key) failing unless the offset happened to be a multiple of the system page size +- Graphics protocol: Fix file descriptor leak that can be triggered by malicious clients + +- Graphics protocol: Make fileopen failure responses generic to avoid leaking any information about failed files + - Clipboard protocol: Report an ``EFBIG`` error to programs that try to write more data to the clipboard than allowed by :opt:`clipboard_max_size`, instead of silently truncating their data. Also fix :opt:`clipboard_max_size` being diff --git a/docs/graphics-protocol.rst b/docs/graphics-protocol.rst index d15c75307..1473d807e 100644 --- a/docs/graphics-protocol.rst +++ b/docs/graphics-protocol.rst @@ -360,6 +360,18 @@ from potentially untrusted sources, terminal emulators **must** refuse to read any device/socket/etc. special files. Only regular files are allowed. Additionally, terminal emulators may refuse to read files in *sensitive* parts of the filesystem, such as :file:`/proc`, :file:`/sys`, :file:`/dev`, etc. +These checks should be made on the path *before* the file is opened, since +merely opening a file can have side-effects. + +Because the escape codes can be emitted by a program running on a remote +machine over SSH or by a sandboxed process, the terminal emulator **must not** +allow such a program to use the responses to these commands to learn anything +about files it cannot read itself. In particular, all failures to read an +image file, be it because the file does not exist, is not readable, is not a +regular file, lies in a sensitive location or is smaller than the client +claimed, must be reported with a single, identical, error response. kitty +answers with ``EBADF:Failed to read image file`` for all of these and writes +the actual reason to its log, which only the local user can see. Local client ^^^^^^^^^^^^^^ diff --git a/kitty/boss.py b/kitty/boss.py index 99a2be457..d2068273e 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -163,6 +163,7 @@ from .utils import ( get_editor, get_new_os_window_size, is_ok_to_read_image_file, + is_ok_to_read_image_path, is_path_in_temp_dir, less_version, log_error, @@ -3486,6 +3487,9 @@ class Boss: with suppress(FileNotFoundError): os.remove(path) + def is_ok_to_read_image_path(self, path: str) -> bool: + return is_ok_to_read_image_path(path) + def is_ok_to_read_image_file(self, path: str, fd: int) -> bool: return is_ok_to_read_image_file(path, fd) diff --git a/kitty/graphics.c b/kitty/graphics.c index 6051151b2..0b06e0d9a 100644 --- a/kitty/graphics.c +++ b/kitty/graphics.c @@ -344,6 +344,28 @@ set_command_failed_response(const char *code, const char *fmt, ...) { goto err; \ } +// Reading an image file can fail for many different reasons: the file does +// not exist, it is not readable, it is of the wrong type, it is smaller than +// the client claimed, etc. The client can be a program running on a remote +// machine over SSH or a sandboxed process, so telling it which of these +// happened lets it probe the filesystem for the existence, type and size of +// files it has no business knowing anything about. So all such failures are +// answered back with one identical, information free, error and the actual +// reason is written to the kitty log, which only the local user can see. +#define IMAGE_FILE_ERROR_CODE "EBADF" +#define IMAGE_FILE_ERROR_MSG "Failed to read image file" +#define FAIL_IMAGE_FILE(...) \ + { \ + log_error(__VA_ARGS__); \ + set_command_failed_response(IMAGE_FILE_ERROR_CODE, "%s", IMAGE_FILE_ERROR_MSG); \ + } +// As above but for use in functions that clean up via a goto err; label. +#define FABRT(...) \ + { \ + FAIL_IMAGE_FILE(__VA_ARGS__); \ + goto err; \ + } + #ifdef __APPLE__ // On macOS POSIX shared memory objects cannot be read() from, they can only be // accessed via mmap(). Unlike with regular files, there is no risk of SIGBUS @@ -357,7 +379,7 @@ copy_from_shm(GraphicsManager *self, int fd, size_t sz, off_t offset) { const size_t delta = (size_t)(offset - page_start), map_sz = sz + delta; void *addr = mmap(0, map_sz, PROT_READ, MAP_SHARED, fd, page_start); if (addr == MAP_FAILED) - ABRT(EIO, "Failed to map shm object fd: %d at offset: %zd with size: %zu with error: [%d] %s", fd, (ssize_t)page_start, map_sz, errno, strerror(errno)); + FABRT("Failed to map shm object fd: %d at offset: %zd with size: %zu with error: [%d] %s", fd, (ssize_t)page_start, map_sz, errno, strerror(errno)); memcpy(ld->buf, (uint8_t *)addr + delta, sz); munmap(addr, map_sz); ld->buf_used = sz; @@ -371,12 +393,12 @@ static bool read_img_file(GraphicsManager *self, int fd, size_t sz, off_t offset, size_t max_to_read, bool is_shm) { LoadData *ld = &self->currently_loading; struct stat s; - if (fstat(fd, &s) != 0) ABRT(EBADF, "Failed to fstat() the fd: %d file with error: [%d] %s", fd, errno, strerror(errno)); + if (fstat(fd, &s) != 0) FABRT("Failed to fstat() the fd: %d file with error: [%d] %s", fd, errno, strerror(errno)); // The graphics protocol specification mandates that only regular files be // read. Reading from FIFOs/devices/etc. can block forever or have // side-effects. POSIX shared memory fds on macOS do not report as regular // files via fstat(), so skip this check for them. - if (!is_shm && !S_ISREG(s.st_mode)) ABRT(EBADF, "The image file with fd: %d is not a regular file", fd); + if (!is_shm && !S_ISREG(s.st_mode)) FABRT("The image file with fd: %d is not a regular file", fd); const size_t available = offset < s.st_size ? (size_t)(s.st_size - offset) : 0; if (!sz) sz = available; if (sz > max_to_read) sz = max_to_read; @@ -387,7 +409,7 @@ read_img_file(GraphicsManager *self, int fd, size_t sz, off_t offset, size_t max // simply results in a short read, which is reported as insufficient data // by the caller. ld->buf = malloc(sz ? sz : 1); - if (!ld->buf) ABRT(ENOMEM, "Out of memory allocating %zu bytes to read image file", sz); + if (!ld->buf) FABRT("Out of memory allocating %zu bytes to read image file", sz); ld->buf_capacity = sz; ld->buf_used = 0; #ifdef __APPLE__ @@ -402,7 +424,7 @@ read_img_file(GraphicsManager *self, int fd, size_t sz, off_t offset, size_t max ssize_t n = pread(fd, ld->buf + ld->buf_used, sz - ld->buf_used, offset + (off_t)ld->buf_used); if (n < 0) { if (errno == EINTR) continue; - ABRT(EIO, "Failed to read from image file fd: %d with error: [%d] %s", fd, errno, strerror(errno)); + FABRT("Failed to read from image file fd: %d with error: [%d] %s", fd, errno, strerror(errno)); } if (!n) break; // EOF, the file is smaller than expected, reported as insufficient data by the caller ld->buf_used += n; @@ -533,7 +555,6 @@ png_from_file_pointer(FILE *fp, const char *path_for_error_messages, uint8_t **d unsigned char *buf = malloc(capacity); if (!buf) { log_error("Out of memory reading PNG file at: %s", path_for_error_messages); - fclose(fp); return false; } while (!feof(fp)) { @@ -543,7 +564,6 @@ png_from_file_pointer(FILE *fp, const char *path_for_error_messages, uint8_t **d if (!new_buf) { free(buf); log_error("Out of memory reading PNG file at: %s", path_for_error_messages); - fclose(fp); return false; } buf = new_buf; @@ -665,10 +685,82 @@ get_free_client_id(const GraphicsManager *self) { #define MAX_DATA_SZ (4u * 100000000u) enum FORMATS { RGB = 24, RGBA = 32, PNG = 100 }; +static bool +is_file_transmission(const unsigned char transmission_type) { + return transmission_type == 'f' || transmission_type == 't' || transmission_type == 's'; +} + +// Ask the boss if path is allowed to be read. This is deliberately based on +// the path alone, so that it can be answered before the file is opened. +static bool +is_ok_to_read_image_path(const char *path) { + if (!global_state.boss) return true; + RAII_PyObject(ret, PyObject_CallMethod(global_state.boss, "is_ok_to_read_image_path", "s", path)); + if (!ret) { + PyErr_Print(); + return false; + } + return ret == Py_True; +} + +// Ask the boss if the already opened file at path is allowed to be read. +static bool +is_ok_to_read_image_fd(const char *path, int fd) { + if (!global_state.boss) return true; + RAII_PyObject(ret, PyObject_CallMethod(global_state.boss, "is_ok_to_read_image_file", "si", path, fd)); + if (!ret) { + PyErr_Print(); + return false; + } + return ret == Py_True; +} + +// Read image data from a file or a POSIX shared memory object. The fd is +// closed on all code paths, not just the successful one, otherwise a client +// can exhaust the process wide file descriptor limit, taking down the entire +// kitty process with it, simply by asking for files it is not allowed to read. +static bool +load_image_data_from_file( + GraphicsManager *self, const GraphicsCommand *g, const unsigned char transmission_type, const char *fname, const size_t max_to_read, bool *was_opened) { + const bool is_shm = transmission_type == 's'; + int fd; + *was_opened = false; + if (is_shm) { + if (fname[0] != '/') { + FAIL_IMAGE_FILE("Failed to open shared memory object: %s with error: %s", fname, "POSIX SHM names must start with /"); + return false; + } + fd = safe_shm_open(fname, O_RDONLY, 0); + } else { + // The policy check has to happen before the file is opened. Merely + // opening a file can have side-effects and whether the open succeeds + // or not tells the client if the file exists, which it must not be + // able to discover for files it is not allowed to read. + if (!is_ok_to_read_image_path(fname)) { + FAIL_IMAGE_FILE("Refusing to read image file: %s as permission was denied", fname); + return false; + } + fd = safe_open(fname, O_CLOEXEC | O_RDONLY | O_NONBLOCK, 0); // O_NONBLOCK so that opening a FIFO pipe does not block + } + if (fd == -1) { + FAIL_IMAGE_FILE("Failed to open file for graphics transmission: %s with error: [%d] %s", fname, errno, strerror(errno)); + return false; + } + *was_opened = true; + bool ok; + // Check again now that the file is open, since fname could have been + // pointed at a different file in between the check above and the open(). + if (!is_shm && !is_ok_to_read_image_fd(fname, fd)) { + FAIL_IMAGE_FILE("Refusing to read image file: %s as permission was denied", fname); + ok = false; + } else ok = read_img_file(self, fd, g->data_sz, g->data_offset, max_to_read, is_shm); + safe_close(fd, __FILE__, __LINE__); + return ok; +} + static Image * load_image_data( GraphicsManager *self, Image *img, const GraphicsCommand *g, const unsigned char transmission_type, const uint32_t data_fmt, const uint8_t *payload) { - int fd; static char fname[2056] = {0}; LoadData *load_data = &self->currently_loading; @@ -694,42 +786,48 @@ load_image_data( case 'f': // file case 't': // temporary file case 's': // POSIX shared memory + { if (g->payload_sz > 2048) ABRT("EINVAL", "Filename too long"); snprintf(fname, sizeof(fname) / sizeof(fname[0]), "%.*s", (int)g->payload_sz, payload); - if (transmission_type == 's') { - if (fname[0] != '/') ABRT("EBADF", "Failed to open file for graphics transmission with error: %s", "POSIX SHM names must start with /"); - fd = safe_shm_open(fname, O_RDONLY, 0); - } else fd = safe_open(fname, O_CLOEXEC | O_RDONLY | O_NONBLOCK, 0); // O_NONBLOCK so that opening a FIFO pipe does not block - if (fd == -1) ABRT("EBADF", "Failed to open file for graphics transmission with error: [%d] %s", errno, strerror(errno)); - if (global_state.boss && transmission_type != 's') { - RAII_PyObject(cret_, PyObject_CallMethod(global_state.boss, "is_ok_to_read_image_file", "si", fname, fd)); - if (cret_ == NULL) { - PyErr_Print(); - ABRT("EBADF", "Failed to check file for read permission"); - } - if (cret_ != Py_True) { - log_error("Refusing to read image file as permission was denied"); - ABRT("EPERM", "Permission denied to read image file"); - } - } // When the data needs further processing the entire (possibly // compressed) payload is needed, otherwise reading more than the // expected number of bytes is pointless. const size_t max_to_read = (g->compressed || data_fmt == PNG) ? MAX_DATA_SZ : load_data->data_sz; - load_data->loading_completed_successfully = read_img_file(self, fd, g->data_sz, g->data_offset, max_to_read, transmission_type == 's'); - safe_close(fd, __FILE__, __LINE__); - if (transmission_type == 't' && strstr(fname, "tty-graphics-protocol") != NULL) { - if (global_state.boss) { - call_boss(safe_delete_temp_file, "s", fname); - } else unlink(fname); - } else if (transmission_type == 's') shm_unlink(fname); - if (!load_data->loading_completed_successfully) return NULL; - break; + bool was_opened = false; + const bool ok = load_image_data_from_file(self, g, transmission_type, fname, max_to_read, &was_opened); + // Client created temporary files and shared memory objects that + // were successfully opened are removed even when loading fails, + // so that they are not left behind to accumulate. + if (was_opened) { + if (transmission_type == 't' && strstr(fname, "tty-graphics-protocol") != NULL) { + if (global_state.boss) { + call_boss(safe_delete_temp_file, "s", fname); + } else unlink(fname); + } else if (transmission_type == 's') shm_unlink(fname); + } + load_data->loading_completed_successfully = ok; + if (!ok) { // the error response has already been set + free_load_data(load_data); + return NULL; + } + } break; default: ABRT("EINVAL", "Unknown transmission type: %c", g->transmission_type); } return img; } +// The number of bytes actually read is the size of the file the client asked +// kitty to read, which it must not learn for files it cannot read itself, so +// when transmitting via a file report the generic image file error instead. +#define ABRT_INSUFFICIENT_DATA(buf_used, data_sz) \ + { \ + if (is_file_transmission(g->transmission_type)) { \ + log_error("Insufficient image data: %zu < %zu", (buf_used), (data_sz)); \ + ABRT(IMAGE_FILE_ERROR_CODE, "%s", IMAGE_FILE_ERROR_MSG); \ + } \ + ABRT("ENODATA", "Insufficient image data: %zu < %zu", (buf_used), (data_sz)); \ + } + static Image * process_image_data(GraphicsManager *self, Image *img, const GraphicsCommand *g, const uint32_t data_fmt) { bool needs_processing = g->compressed || data_fmt == PNG; @@ -765,11 +863,11 @@ process_image_data(GraphicsManager *self, Image *img, const GraphicsCommand *g, #undef IB self->currently_loading.data = self->currently_loading.buf; if (self->currently_loading.buf_used < self->currently_loading.data_sz) { - ABRT("ENODATA", "Insufficient image data: %zu < %zu", self->currently_loading.buf_used, self->currently_loading.data_sz); + ABRT_INSUFFICIENT_DATA(self->currently_loading.buf_used, self->currently_loading.data_sz); } } else { if (self->currently_loading.buf_used < self->currently_loading.data_sz) { - ABRT("ENODATA", "Insufficient image data: %zu < %zu", self->currently_loading.buf_used, self->currently_loading.data_sz); + ABRT_INSUFFICIENT_DATA(self->currently_loading.buf_used, self->currently_loading.data_sz); } else self->currently_loading.data = self->currently_loading.buf; self->currently_loading.loading_completed_successfully = true; } diff --git a/kitty/utils.py b/kitty/utils.py index 84a2c76d4..e2e6d300b 100644 --- a/kitty/utils.py +++ b/kitty/utils.py @@ -644,9 +644,37 @@ def is_path_in_temp_dir(path: str) -> bool: return False +def is_ok_to_read_image_path(path: str) -> bool: + """Check if path may be read, based only on the path itself. + + This must be done *before* opening the file, since merely opening a file + can have side effects and the success or failure of the open leaks the + existence of the file to the, possibly remote or sandboxed, client. + """ + if not path: + return False + path = os.path.abspath(os.path.realpath(path)) + parts = path.split(os.sep)[1:] + if len(parts) < 1: + return False + if parts[0] in ('sys', 'proc', 'dev'): + if parts[0] == 'dev': + return len(parts) > 2 and parts[1] == 'shm' + return False + return True + + def is_ok_to_read_image_file(path: str, fd: int) -> bool: + """Check if the already opened fd for path may be read. + + This re-does the path based checks, since path could have been swapped for + a different file after :func:`is_ok_to_read_image_path` was called, and + additionally verifies that the opened file is a regular file. + """ import stat + if not is_ok_to_read_image_path(path): + return False path = os.path.abspath(os.path.realpath(path)) try: path_stat = os.stat(path, follow_symlinks=True) @@ -655,13 +683,6 @@ def is_ok_to_read_image_file(path: str, fd: int) -> bool: return False if not os.path.samestat(path_stat, fd_stat): return False - parts = path.split(os.sep)[1:] - if len(parts) < 1: - return False - if parts[0] in ('sys', 'proc', 'dev'): - if parts[0] == 'dev': - return len(parts) > 2 and parts[1] == 'shm' - return False return stat.S_ISREG(fd_stat.st_mode) diff --git a/kitty_tests/datatypes.py b/kitty_tests/datatypes.py index f5686b79e..4d094c880 100644 --- a/kitty_tests/datatypes.py +++ b/kitty_tests/datatypes.py @@ -32,6 +32,7 @@ from kitty.fast_data_types import Cursor as C from kitty.rgb import to_color from kitty.utils import ( is_ok_to_read_image_file, + is_ok_to_read_image_path, is_path_in_temp_dir, lock_with_file, sanitize_title, @@ -625,6 +626,12 @@ class TestDataTypes(BaseTest): if os.path.exists(path): with open(path) as pf: self.assertFalse(is_ok_to_read_image_file(path, pf.fileno()), path) + # The path based check must reject protected locations without needing + # the file to be opened first, since opening it leaks its existence + for path in ('', '/proc/self/cmdline', '/proc/does-not-exist', '/sys/kernel', '/dev/null', '/dev/does-not-exist'): + self.assertFalse(is_ok_to_read_image_path(path), path) + for path in ('/tmp/a.png', '/dev/shm/a.png', os.path.join(tempfile.gettempdir(), 'a.png')): + self.assertTrue(is_ok_to_read_image_path(path), path) fifo = os.path.join(tempfile.gettempdir(), 'test-kitty-fifo') os.mkfifo(fifo) fifo_fd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK) diff --git a/kitty_tests/graphics.py b/kitty_tests/graphics.py index fb65d69b4..e7fa1bfab 100644 --- a/kitty_tests/graphics.py +++ b/kitty_tests/graphics.py @@ -11,7 +11,6 @@ from contextlib import suppress from dataclasses import dataclass from io import BytesIO -from kitty.constants import is_macos from kitty.fast_data_types import base64_decode, base64_encode, load_png_data, shm_unlink, shm_write from .base import BaseTest, parse_bytes @@ -23,6 +22,12 @@ except ImportError: png_data = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==') +def num_open_fds(): + with suppress(OSError): + return len(os.listdir('/proc/self/fd')) + return len(os.listdir('/dev/fd')) + + def send_command(screen, cmd, payload=b''): cmd = '\033_G' + cmd if payload: @@ -508,6 +513,11 @@ class TestGraphics(BaseTest): def test_load_images_from_file_edge_cases(self): s, g, pl, sl = load_helpers(self) random_data = byte_block(32 * 1024) + # Failures to read an image file must all be reported with the same + # response, otherwise a client, which can be a program running on a + # remote machine or in a sandbox, can use the response to probe the + # filesystem for the existence, type and size of files. + generic_error = 'EBADF:Failed to read image file' with tempfile.NamedTemporaryFile(prefix='tty-graphics-protocol-') as f: # A window of the file specified with a non page aligned offset @@ -515,21 +525,42 @@ class TestGraphics(BaseTest): sl(f.name, s=1024, v=8, t='f', S=len(random_data), O=3, expecting_data=random_data) # A file that is truncated after the size declared in the command - # must be reported as insufficient data rather than crashing + # must be reported as a generic failure rather than crashing or + # leaking the size of the file f.seek(0), f.truncate(), f.write(random_data[:128]), f.flush() - self.ae(pl(f.name, s=1024, v=8, t='f', S=len(random_data)), f'ENODATA:Insufficient image data: 128 < {len(random_data)}') + self.ae(pl(f.name, s=1024, v=8, t='f', S=len(random_data)), generic_error) # Ditto when the size is not declared and is read from the file itself - self.ae(pl(f.name, s=1024, v=8, t='f'), f'ENODATA:Insufficient image data: 128 < {len(random_data)}') + self.ae(pl(f.name, s=1024, v=8, t='f'), generic_error) # An offset past the end of the file - self.ae(pl(f.name, s=1024, v=8, t='f', O=4096), f'ENODATA:Insufficient image data: 0 < {len(random_data)}') + self.ae(pl(f.name, s=1024, v=8, t='f', O=4096), generic_error) # Only regular files may be read with tempfile.TemporaryDirectory(prefix='tty-graphics-protocol-') as tdir: fifo = os.path.join(tdir, 'fifo') os.mkfifo(fifo) - self.assertTrue(pl(fifo, s=1024, v=8, t='f').startswith('EBADF:'), 'Reading from a FIFO was not refused') + self.ae(pl(fifo, s=1024, v=8, t='f'), generic_error, 'Reading from a FIFO was not refused') + + # Neither the existence nor the type of a file may be leaked, so a + # non-existent file, a directory and a file that is too small must + # all give byte for byte identical responses + small = os.path.join(tdir, 'small') + with open(small, 'wb') as sf: + sf.write(random_data[:7]) + for path in (os.path.join(tdir, 'does-not-exist'), tdir, small): + self.ae(pl(path, s=1024, v=8, t='f'), generic_error, path) + + # Failing to read a file must not leak the file descriptor opened + # for it, else a client can exhaust the process wide fd limit + paths = (small, tdir, os.path.join(tdir, 'does-not-exist'), fifo) + for path in paths: + pl(path, s=1024, v=8, t='f') # warm up any lazily opened fds + before = num_open_fds() + for i in range(64): + for path in paths: + pl(path, s=1024, v=8, t='f') + self.ae(before, num_open_fds(), 'File descriptors were leaked when failing to read image files') # A window of a shared memory object with a non page aligned offset name = '/kitty-test-shm-offset' @@ -540,16 +571,67 @@ class TestGraphics(BaseTest): # A shared memory object truncated to less than the declared size name = '/kitty-test-shm-truncated' shm_write(name, random_data[:64]) - # macOS rounds the size of a shared memory object up to a multiple of - # the page size, zero filling the padding, and reports the rounded up - # size via fstat(), so more data than was written is available there. - available = 64 - if is_macos: - page_size = os.sysconf('SC_PAGESIZE') - available = min(len(random_data), (64 + page_size - 1) // page_size * page_size) - self.ae(pl(name, s=1024, v=8, t='s', S=len(random_data)), f'ENODATA:Insufficient image data: {available} < {len(random_data)}') + self.ae(pl(name, s=1024, v=8, t='s', S=len(random_data)), generic_error) self.assertRaises(FileNotFoundError, shm_unlink, name) # check that the object was deleted + # A shared memory object that could not be opened must also be + # removed, and reported with the same generic error + self.ae(pl('/kitty-test-shm-missing', s=1024, v=8, t='s'), generic_error) + self.ae(pl('kitty-test-shm-no-leading-slash', s=1024, v=8, t='s'), generic_error) + + s.reset() + self.assertEqual(g.disk_cache.total_size, 0) + + def test_graphics_file_reading_policy(self): + from kitty.fast_data_types import set_boss + from kitty.utils import is_ok_to_read_image_file, is_ok_to_read_image_path + + class Boss: + def __init__(self): + self.path_checks = [] + self.fd_checks = [] + + def is_ok_to_read_image_path(self, path): + self.path_checks.append(path) + return is_ok_to_read_image_path(path) + + def is_ok_to_read_image_file(self, path, fd): + self.fd_checks.append(path) + return is_ok_to_read_image_file(path, fd) + + def safe_delete_temp_file(self, path): + with suppress(FileNotFoundError): + os.remove(path) + + s, g, pl, sl = load_helpers(self) + random_data = byte_block(32 * 1024) + generic_error = 'EBADF:Failed to read image file' + boss = Boss() + set_boss(boss) + try: + # Files in protected locations must be refused based on their path + # alone, without ever being opened, since both opening a file and + # the error from a failed open leak its existence + protected = ('/proc/self/cmdline', '/proc/does-not-exist', '/sys/kernel/does-not-exist', '/dev/null', '/dev/does-not-exist') + for path in protected: + self.ae(pl(path, s=1024, v=8, t='f'), generic_error, path) + self.assertFalse(boss.fd_checks, 'A file in a protected location was opened before the policy was applied') + self.ae(len(boss.path_checks), len(protected)) + + # Refusing to read a file must not leak the fd opened for it + before = num_open_fds() + for i in range(64): + for path in protected: + pl(path, s=1024, v=8, t='f') + self.ae(before, num_open_fds(), 'File descriptors were leaked when refusing to read image files') + + # Allowed files are still readable + with tempfile.NamedTemporaryFile() as f: + f.write(random_data), f.flush() + sl(f.name, s=1024, v=8, t='f', expecting_data=random_data) + self.ae(boss.fd_checks[-1], f.name) + finally: + set_boss(None) s.reset() self.assertEqual(g.disk_cache.total_size, 0)