diff --git a/kitty/dnd.c b/kitty/dnd.c index 5907ae6a6..fcace2c41 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -9,6 +9,12 @@ #include "base64.h" #include "control-codes.h" #include "iqsort.h" +#include +#include +#include +#include +#include +#include // In test mode, this callable is invoked instead of schedule_write_to_child_if_possible. // It receives (window_id: int, data: bytes) and its return value is ignored. @@ -45,6 +51,25 @@ free_pending(PendingData *pending) { zero_at_ptr(pending); } +static void +drop_free_dir_handle(DirHandle *h) { + free(h->path); + for (size_t i = 0; i < h->num_entries; i++) free(h->entries[i]); + free(h->entries); + zero_at_ptr(h); +} + +static void +drop_free_dir_handles(Window *w) { + for (size_t i = 0; i < w->drop.num_dir_handles; i++) + drop_free_dir_handle(&w->drop.dir_handles[i]); + free(w->drop.dir_handles); + w->drop.dir_handles = NULL; + w->drop.num_dir_handles = 0; + w->drop.dir_handles_capacity = 0; + w->drop.next_dir_handle_id = 0; +} + void drop_free_data(Window *w) { drop_free_offered_mimes(w); @@ -53,6 +78,7 @@ drop_free_data(Window *w) { free(w->drop.registered_mimes); w->drop.registered_mimes = NULL; free(w->drop.uri_list); w->drop.uri_list = NULL; free(w->drop.getting_data_for_mime); w->drop.getting_data_for_mime = NULL; + drop_free_dir_handles(w); } static void @@ -325,6 +351,8 @@ get_errno_name(int err) { case EPERM: return "EPERM"; case ENOENT: return "ENOENT"; case EIO: return "EIO"; + case EINVAL: return "EINVAL"; + case ENOMEM: return "ENOMEM"; default: return "EUNKNOWN"; } } @@ -337,6 +365,11 @@ drop_send_error(Window *w, int error_code) { queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, buf, header_size, e, strlen(e), false); } +void +drop_send_einval(Window *w) { + drop_send_error(w, EINVAL); +} + void drop_request_data(Window *w, const char *mime) { if (w->drop.getting_data_for_mime) { free(w->drop.getting_data_for_mime); w->drop.getting_data_for_mime = NULL; } @@ -370,6 +403,387 @@ drop_dispatch_data(Window *w, const char *mime, const char *data, ssize_t sz) { } } +// ---- Remote file / directory transfer (t=s, t=d) ---- + +static void +url_decode_inplace(char *str) { + char *src = str, *dst = str; + while (*src) { + if (*src == '%' && src[1] && src[2]) { + unsigned int hi = 0, lo = 0; + char c1 = src[1], c2 = src[2]; + if (c1 >= '0' && c1 <= '9') hi = c1 - '0'; + else if (c1 >= 'a' && c1 <= 'f') hi = c1 - 'a' + 10; + else if (c1 >= 'A' && c1 <= 'F') hi = c1 - 'A' + 10; + else { *dst++ = *src++; continue; } + if (c2 >= '0' && c2 <= '9') lo = c2 - '0'; + else if (c2 >= 'a' && c2 <= 'f') lo = c2 - 'a' + 10; + else if (c2 >= 'A' && c2 <= 'F') lo = c2 - 'A' + 10; + else { *dst++ = *src++; continue; } + *dst++ = (char)((hi << 4) | lo); + src += 3; + } else { + *dst++ = *src++; + } + } + *dst = 0; +} + +/* Return the nth (0-based) file path from a text/uri-list. + * On success returns true and *path_out is a malloc'd absolute resolved path. + * On failure returns false and *error_out points to a static error string. */ +static bool +get_nth_file_url(const char *uri_list, size_t uri_list_sz, int n, char **path_out, const char **error_out) { + *path_out = NULL; + RAII_ALLOC(char, buf, malloc(uri_list_sz + 1)); + if (!buf) { *error_out = "ENOMEM"; return false; } + memcpy(buf, uri_list, uri_list_sz); + buf[uri_list_sz] = 0; + + const char *found_line = NULL; + char *p = buf; + while (*p) { + char *eol = p + strcspn(p, "\r\n"); + char saved = *eol; *eol = 0; + /* trim trailing whitespace */ + char *end = eol; + while (end > p && (end[-1] == ' ' || end[-1] == '\t')) { end--; *end = 0; } + if (*p && *p != '#') { + if (n <= 0) { found_line = p; break; } + n--; + } + if (saved == 0) break; + p = eol + 1; + while (*p == '\r' || *p == '\n') p++; + } + + if (!found_line) { *error_out = "ENOENT"; return false; } + + /* Must be a file:// URL */ + if (strncmp(found_line, "file://", 7) != 0) { *error_out = "EUNKNOWN"; return false; } + + const char *rest = found_line + 7; + const char *slash = strchr(rest, '/'); + if (!slash) { *error_out = "EINVAL"; return false; } + + /* Host part must be empty or "localhost" */ + size_t host_len = (size_t)(slash - rest); + if (host_len > 0 && !(host_len == 9 && strncasecmp(rest, "localhost", 9) == 0)) { + *error_out = "EUNKNOWN"; return false; + } + + RAII_ALLOC(char, path, strdup(slash)); + if (!path) { *error_out = "ENOMEM"; return false; } + url_decode_inplace(path); + if (path[0] != '/') { *error_out = "EINVAL"; return false; } + + char resolved[PATH_MAX]; + if (!realpath(path, resolved)) { + switch (errno) { + case ENOENT: case ENOTDIR: *error_out = "ENOENT"; break; + case EACCES: case EPERM: *error_out = "EPERM"; break; + case ELOOP: *error_out = "ENOENT"; break; + default: *error_out = "EINVAL"; break; + } + return false; + } + + *path_out = strdup(resolved); + if (!*path_out) { *error_out = "ENOMEM"; return false; } + return true; +} + +/* Send error using a literal string (for cases where we have a string, not int). */ +static void +drop_send_error_str(Window *w, const char *err_name) { + char buf[128]; + int header_size = snprintf(buf, sizeof(buf), "\x1b]%d;t=R", DND_CODE); + queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, buf, header_size, err_name, strlen(err_name), false); +} + +/* Read a regular file and send its contents as t=r chunks followed by an + * empty end-of-data t=r. */ +static void +drop_send_file_data(Window *w, const char *path) { + struct stat st; + if (stat(path, &st) < 0) { + switch (errno) { + case ENOENT: case ENOTDIR: drop_send_error(w, ENOENT); break; + case EACCES: case EPERM: drop_send_error(w, EPERM); break; + default: drop_send_error(w, EIO); break; + } + return; + } + if (!S_ISREG(st.st_mode)) { drop_send_error(w, EINVAL); return; } + + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + switch (errno) { + case ENOENT: case ENOTDIR: drop_send_error(w, ENOENT); break; + case EACCES: case EPERM: drop_send_error(w, EPERM); break; + default: drop_send_error(w, EIO); break; + } + return; + } + + size_t data_sz = (size_t)st.st_size; + char *data = NULL; + if (data_sz) { + data = malloc(data_sz); + if (!data) { close(fd); drop_send_error(w, EIO); return; } + size_t done = 0; + while (done < data_sz) { + ssize_t n = read(fd, data + done, data_sz - done); + if (n < 0) { + if (errno == EINTR) continue; + free(data); close(fd); drop_send_error(w, EIO); return; + } + if (n == 0) break; + done += (size_t)n; + } + data_sz = done; + } + close(fd); + + char hdr[128]; + int hdr_sz = snprintf(hdr, sizeof(hdr), "\x1b]%d;t=r", DND_CODE); + if (data_sz) + queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, hdr, hdr_sz, data, data_sz, true); + free(data); + /* end-of-data signal */ + queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, hdr, hdr_sz, NULL, 0, true); +} + +/* Allocate a new DirHandle for the given path and entries (takes ownership of + * entries array and its strings). Returns the new handle id. */ +static uint32_t +drop_alloc_dir_handle(Window *w, const char *path, char **entries, size_t num_entries) { + ensure_space_for(&w->drop, dir_handles, DirHandle, w->drop.num_dir_handles + 1, dir_handles_capacity, 4, true); + w->drop.next_dir_handle_id++; + if (w->drop.next_dir_handle_id == 0) w->drop.next_dir_handle_id = 1; + DirHandle *h = &w->drop.dir_handles[w->drop.num_dir_handles++]; + zero_at_ptr(h); + h->id = w->drop.next_dir_handle_id; + h->path = strdup(path); + if (!h->path) fatal("Out of memory"); + h->entries = entries; + h->num_entries = num_entries; + return h->id; +} + +static DirHandle * +drop_find_dir_handle(Window *w, uint32_t id) { + for (size_t i = 0; i < w->drop.num_dir_handles; i++) + if (w->drop.dir_handles[i].id == id) return &w->drop.dir_handles[i]; + return NULL; +} + +/* Open a directory, build the null-separated listing, create a handle, and + * send the listing to the client as a t=d:x=handle_id response. */ +static void +drop_send_dir_listing(Window *w, const char *path) { + struct stat st; + if (stat(path, &st) < 0) { drop_send_error(w, EIO); return; } + + DIR *dir = opendir(path); + if (!dir) { + switch (errno) { + case ENOENT: case ENOTDIR: drop_send_error(w, ENOENT); break; + case EACCES: case EPERM: drop_send_error(w, EPERM); break; + default: drop_send_error(w, EIO); break; + } + return; + } + + /* Build null-separated payload: unique_id\0entry1\0entry2\0... */ + size_t payload_cap = 4096, payload_sz = 0; + char *payload = malloc(payload_cap); + if (!payload) { closedir(dir); drop_send_error(w, EIO); return; } + + /* First entry: unique identifier (device:inode) */ + char uid[64]; + int uid_len = snprintf(uid, sizeof(uid), "%llu:%llu", + (unsigned long long)st.st_dev, + (unsigned long long)st.st_ino); + +#define APPEND(s, n) do { \ + size_t _n = (size_t)(n); \ + size_t _need = payload_sz + _n + 1; \ + if (_need > payload_cap) { \ + while (payload_cap < _need) payload_cap *= 2; \ + char *_np = realloc(payload, payload_cap); \ + if (!_np) { free(payload); closedir(dir); drop_send_error(w, EIO); return; } \ + payload = _np; \ + } \ + memcpy(payload + payload_sz, (s), _n); \ + payload_sz += _n; \ + payload[payload_sz++] = 0; \ +} while(0) + + APPEND(uid, uid_len); + + /* Collect directory entries */ + size_t ents_cap = 16, ents_num = 0; + char **ents = malloc(sizeof(char *) * ents_cap); + if (!ents) { free(payload); closedir(dir); drop_send_error(w, EIO); return; } + + struct dirent *de; + while ((de = readdir(dir)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + + unsigned char dtype = de->d_type; + if (dtype == DT_UNKNOWN) { + /* Fall back to lstat when d_type is unavailable */ + char full[PATH_MAX]; + if (snprintf(full, sizeof(full), "%s/%s", path, de->d_name) >= (int)sizeof(full)) continue; + struct stat est; + if (lstat(full, &est) < 0) continue; + if (S_ISREG(est.st_mode)) dtype = DT_REG; + else if (S_ISDIR(est.st_mode)) dtype = DT_DIR; + else if (S_ISLNK(est.st_mode)) dtype = DT_LNK; + else continue; + } + if (dtype != DT_REG && dtype != DT_DIR && dtype != DT_LNK) continue; + + if (ents_num >= ents_cap) { + ents_cap *= 2; + char **ne = realloc(ents, sizeof(char *) * ents_cap); + if (!ne) { + for (size_t i = 0; i < ents_num; i++) free(ents[i]); + free(ents); free(payload); closedir(dir); + drop_send_error(w, EIO); return; + } + ents = ne; + } + ents[ents_num] = strdup(de->d_name); + if (!ents[ents_num]) { + for (size_t i = 0; i < ents_num; i++) free(ents[i]); + free(ents); free(payload); closedir(dir); + drop_send_error(w, EIO); return; + } + ents_num++; + + APPEND(de->d_name, strlen(de->d_name)); + } + closedir(dir); + +#undef APPEND + + uint32_t handle_id = drop_alloc_dir_handle(w, path, ents, ents_num); + + char hdr[128]; + int hdr_sz = snprintf(hdr, sizeof(hdr), "\x1b]%d;t=d:x=%u", DND_CODE, (unsigned)handle_id); + /* payload_sz includes a trailing null; omit it – the null-separated format + * does not require a trailing null after the last entry. */ + size_t send_sz = payload_sz > 0 ? payload_sz - 1 : 0; + if (send_sz) + queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, hdr, hdr_sz, payload, send_sz, true); + free(payload); + /* end-of-listing signal (empty payload) */ + queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, hdr, hdr_sz, NULL, 0, true); +} + +/* Handle a t=s request: send the file/directory at URI-list index idx. */ +void +drop_request_uri_data(Window *w, const char *payload, size_t payload_sz) { + if (!w->drop.uri_list || !w->drop.uri_list_sz) { + drop_send_error(w, EINVAL); return; + } + + /* Payload format: "text/uri-list:idx" */ + const char *colon = memchr(payload, ':', payload_sz); + if (!colon) { drop_send_error(w, EINVAL); return; } + + size_t mime_len = (size_t)(colon - payload); + if (mime_len != 13 || strncmp(payload, "text/uri-list", 13) != 0) { + drop_send_error(w, EINVAL); return; + } + + const char *idx_str = colon + 1; + size_t idx_len = payload_sz - mime_len - 1; + char idx_buf[32]; + if (!idx_len || idx_len >= sizeof(idx_buf)) { drop_send_error(w, EINVAL); return; } + memcpy(idx_buf, idx_str, idx_len); + idx_buf[idx_len] = 0; + + char *endp; + long idx = strtol(idx_buf, &endp, 10); + if (endp == idx_buf || *endp != 0 || idx < 0) { drop_send_error(w, EINVAL); return; } + + char *path = NULL; + const char *err = NULL; + if (!get_nth_file_url(w->drop.uri_list, w->drop.uri_list_sz, (int)idx, &path, &err)) { + drop_send_error_str(w, err); + return; + } + + struct stat st; + if (stat(path, &st) < 0) { + free(path); + switch (errno) { + case ENOENT: case ENOTDIR: drop_send_error(w, ENOENT); break; + case EACCES: case EPERM: drop_send_error(w, EPERM); break; + default: drop_send_error(w, EIO); break; + } + return; + } + + if (S_ISDIR(st.st_mode)) { + drop_send_dir_listing(w, path); + } else if (S_ISREG(st.st_mode)) { + drop_send_file_data(w, path); + } else { + drop_send_error(w, EINVAL); + } + free(path); +} + +/* Handle a t=d request from the client. + * handle_id: the directory handle (x= key). + * entry_num: 0 means close the handle; >=1 means read that entry (1-based). */ +void +drop_handle_dir_request(Window *w, uint32_t handle_id, int32_t entry_num) { + if (!handle_id) { drop_send_error(w, EINVAL); return; } + + DirHandle *h = drop_find_dir_handle(w, handle_id); + if (!h) { drop_send_error(w, EINVAL); return; } + + if (entry_num == 0) { + /* Close the handle */ + size_t hidx = (size_t)(h - w->drop.dir_handles); + drop_free_dir_handle(h); + remove_i_from_array(w->drop.dir_handles, hidx, w->drop.num_dir_handles); + return; + } + + /* Read the entry at 1-based index */ + size_t eidx = (size_t)(entry_num - 1); + if (eidx >= h->num_entries) { drop_send_error(w, ENOENT); return; } + + char full[PATH_MAX]; + if (snprintf(full, sizeof(full), "%s/%s", h->path, h->entries[eidx]) >= (int)sizeof(full)) { + drop_send_error(w, EIO); return; + } + + struct stat st; + if (stat(full, &st) < 0) { + switch (errno) { + case ENOENT: case ENOTDIR: case ELOOP: drop_send_error(w, ENOENT); break; + case EACCES: case EPERM: drop_send_error(w, EPERM); break; + default: drop_send_error(w, EIO); break; + } + return; + } + + if (S_ISDIR(st.st_mode)) { + drop_send_dir_listing(w, full); + } else if (S_ISREG(st.st_mode)) { + drop_send_file_data(w, full); + } else { + drop_send_error(w, EINVAL); + } +} + void drop_left_child(Window *w) { w->drop.hovered = false; diff --git a/kitty/dnd.h b/kitty/dnd.h index e79852748..156c348bf 100644 --- a/kitty/dnd.h +++ b/kitty/dnd.h @@ -13,6 +13,9 @@ void drop_move_on_child(Window *w, const char **mimes, size_t num_mimes, bool is void drop_left_child(Window *w); void drop_free_data(Window *w); void drop_request_data(Window *w, const char *mime); +void drop_send_einval(Window *w); +void drop_request_uri_data(Window *w, const char *payload, size_t payload_sz); +void drop_handle_dir_request(Window *w, uint32_t handle_id, int32_t entry_num); void drop_set_status(Window *w, int operation, const char *payload, size_t payload_sz, bool more); size_t drop_update_mimes(Window *w, const char **allowed_mimes, size_t allowed_mimes_count); void drop_dispatch_data(Window *w, const char *mime_type, const char *data, ssize_t sz); diff --git a/kitty/screen.c b/kitty/screen.c index aaebcdc82..a92beca6d 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -1529,6 +1529,18 @@ screen_handle_dnd_command(Screen *self, const DnDCommand *cmd, const uint8_t *pa drop_request_data(w, buf); } } break; + case 's': { + if (cmd->payload_sz) + drop_request_uri_data(w, (const char*)payload, cmd->payload_sz); + else + drop_send_einval(w); + } break; + case 'd': { + if (cmd->cell_x > 0) + drop_handle_dir_request(w, (uint32_t)cmd->cell_x, cmd->cell_y); + else + drop_send_einval(w); + } break; } } diff --git a/kitty/state.h b/kitty/state.h index 3567ac65a..c8bfc1a90 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -225,6 +225,13 @@ typedef struct PendingData { PendingEntry *items; size_t count, capacity; } PendingData; +typedef struct DirHandle { + char *path; /* absolute path of the directory (malloc'd) */ + char **entries; /* array of entry names (each malloc'd) */ + size_t num_entries; + uint32_t id; /* handle id, 1-based; 0 = invalid */ +} DirHandle; + typedef struct Window { id_type id; bool visible; @@ -269,6 +276,9 @@ typedef struct Window { char *accepted_mimes; size_t accepted_mimes_sz; int accepted_operation; bool accept_in_progress; char *getting_data_for_mime; + + DirHandle *dir_handles; size_t num_dir_handles, dir_handles_capacity; + uint32_t next_dir_handle_id; } drop; } Window; diff --git a/kitty_tests/dnd.py b/kitty_tests/dnd.py index 4350bdf3d..3c11ff6f6 100644 --- a/kitty_tests/dnd.py +++ b/kitty_tests/dnd.py @@ -58,6 +58,28 @@ def client_request_data(mime: str = '', client_id: int = 0) -> bytes: return _osc(f'{meta};{mime}') +def client_request_uri_data(idx: int, client_id: int = 0) -> bytes: + """Escape code a client sends to request a file from the URI list (t=s ; text/uri-list:idx).""" + meta = f'{DND_CODE};t=s' + if client_id: + meta += f':i={client_id}' + return _osc(f'{meta};text/uri-list:{idx}') + + +def client_dir_read(handle_id: int, entry_num: int | None = None, client_id: int = 0) -> bytes: + """Escape code for a directory request (t=d:x=handle_id[:y=entry_num]). + + * entry_num=None → close the directory handle. + * entry_num>=1 → read that entry (1-based). + """ + meta = f'{DND_CODE};t=d:x={handle_id}' + if entry_num is not None: + meta += f':y={entry_num}' + if client_id: + meta += f':i={client_id}' + return _osc(meta) + + # ---- escape-code decoder used by assertions --------------------------------- _OSC_RE = re.compile( @@ -474,3 +496,341 @@ class TestDnDProtocol(BaseTest): # Only the end signal should be present. self.assertEqual(len(r_events), 1, raw) self.ae(r_events[0]['payload'], b'') + + # ---- t=s / t=d (remote file/directory transfer) tests ---------------- + + def _setup_uri_drop(self, screen, wid, cap, uri_list_data: bytes, mimes=None): + """Register, drop, deliver text/uri-list data, discard move/drop events.""" + if mimes is None: + mimes = ['text/plain', 'text/uri-list'] + parse_bytes(screen, client_register('text/plain text/uri-list')) + dnd_test_set_mouse_pos(wid, 0, 0, 0, 0) + dnd_test_fake_drop_event(wid, True, mimes) + cap.consume(wid) + # Client requests and receives the URI list + parse_bytes(screen, client_request_data('text/uri-list')) + dnd_test_fake_drop_data(wid, 'text/uri-list', uri_list_data) + cap.consume(wid) # discard t=r data for text/uri-list + + def test_uri_file_transfer_basic(self) -> None: + """t=s request sends the content of a regular file as t=r chunks.""" + import os, tempfile + content = b'Hello, remote DnD world!\n' * 100 + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(content) + fpath = f.name + try: + uri_list = f'file://{fpath}\r\n'.encode() + with dnd_test_window() as (osw, wid, screen, cap): + self._setup_uri_drop(screen, wid, cap, uri_list) + parse_bytes(screen, client_request_uri_data(0)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + r_events = [e for e in events if e['type'] == 'r'] + self.assertTrue(r_events, 'no t=r events') + combined = b''.join(e['payload'] for e in r_events) + self.ae(combined, content) + # Last chunk must be the empty end-of-data signal + self.ae(r_events[-1]['payload'], b'') + finally: + os.unlink(fpath) + + def test_uri_file_transfer_integrity(self) -> None: + """File content is transferred byte-for-byte (binary integrity).""" + import os, tempfile + # Use binary content with all byte values to check integrity + content = bytes(range(256)) * 512 # 128 KiB + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(content) + fpath = f.name + try: + uri_list = f'file://{fpath}\r\n'.encode() + with dnd_test_window() as (osw, wid, screen, cap): + self._setup_uri_drop(screen, wid, cap, uri_list) + parse_bytes(screen, client_request_uri_data(0)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + r_events = [e for e in events if e['type'] == 'r'] + # Exclude the empty end-of-data entry when concatenating + combined = b''.join(e['payload'] for e in r_events if e['payload']) + self.ae(combined, content) + finally: + os.unlink(fpath) + + def test_uri_file_transfer_enoent(self) -> None: + """t=s with an out-of-range index returns ENOENT.""" + uri_list = b'file:///tmp/no_such_file_exists_dnd_test_xyz\r\n' + with dnd_test_window() as (osw, wid, screen, cap): + self._setup_uri_drop(screen, wid, cap, uri_list) + # Index 0 refers to a non-existent file + parse_bytes(screen, client_request_uri_data(0)) + events = self._get_events(cap, wid) + self.assertEqual(len(events), 1, events) + self.ae(events[0]['type'], 'R') + self.assertIn(events[0]['payload'].strip(), [b'ENOENT', b'EPERM']) + + def test_uri_file_transfer_out_of_bounds(self) -> None: + """t=s with an index beyond the URI list returns ENOENT.""" + import os, tempfile + with tempfile.NamedTemporaryFile(delete=False) as f: + fpath = f.name + try: + uri_list = f'file://{fpath}\r\n'.encode() + with dnd_test_window() as (osw, wid, screen, cap): + self._setup_uri_drop(screen, wid, cap, uri_list) + parse_bytes(screen, client_request_uri_data(99)) # out of range + events = self._get_events(cap, wid) + self.assertEqual(len(events), 1, events) + self.ae(events[0]['type'], 'R') + self.ae(events[0]['payload'].strip(), b'ENOENT') + finally: + os.unlink(fpath) + + def test_uri_request_without_uri_list_returns_einval(self) -> None: + """t=s without prior text/uri-list request returns EINVAL.""" + import os, tempfile + with tempfile.NamedTemporaryFile(delete=False) as f: + fpath = f.name + try: + with dnd_test_window() as (osw, wid, screen, cap): + parse_bytes(screen, client_register('text/plain')) + dnd_test_set_mouse_pos(wid, 0, 0, 0, 0) + dnd_test_fake_drop_event(wid, True, ['text/plain', 'text/uri-list']) + cap.consume(wid) + # Do NOT request text/uri-list first + parse_bytes(screen, client_request_uri_data(0)) + events = self._get_events(cap, wid) + self.assertEqual(len(events), 1, events) + self.ae(events[0]['type'], 'R') + self.ae(events[0]['payload'].strip(), b'EINVAL') + finally: + os.unlink(fpath) + + def test_uri_non_regular_file_returns_einval(self) -> None: + """t=s for a non-regular file (e.g. /dev/null) returns EINVAL.""" + uri_list = b'file:///dev/null\r\n' + with dnd_test_window() as (osw, wid, screen, cap): + self._setup_uri_drop(screen, wid, cap, uri_list) + parse_bytes(screen, client_request_uri_data(0)) + events = self._get_events(cap, wid) + self.assertEqual(len(events), 1, events) + self.ae(events[0]['type'], 'R') + self.ae(events[0]['payload'].strip(), b'EINVAL') + + def test_uri_directory_transfer_tree(self) -> None: + """Full directory tree transfer: listing, sub-dirs, file integrity.""" + import os, tempfile, hashlib + + # Build a tree: root/ a.txt b/ b/c.txt b/d/ b/d/e.txt + with tempfile.TemporaryDirectory() as root: + a_content = b'file a content\n' * 50 + bc_content = bytes(range(256)) * 20 # binary data + bde_content = b'deep nested file\n' + + (open(os.path.join(root, 'a.txt'), 'wb')).write(a_content) + os.makedirs(os.path.join(root, 'b', 'd')) + (open(os.path.join(root, 'b', 'c.txt'), 'wb')).write(bc_content) + (open(os.path.join(root, 'b', 'd', 'e.txt'), 'wb')).write(bde_content) + + uri_list = f'file://{root}\r\n'.encode() + with dnd_test_window() as (osw, wid, screen, cap): + self._setup_uri_drop(screen, wid, cap, uri_list) + + # Request the root directory (idx=0) + parse_bytes(screen, client_request_uri_data(0)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + d_events = [e for e in events if e['type'] == 'd'] + self.assertTrue(d_events, 'expected t=d listing for root') + + root_listing_payload = b''.join( + chunk for e in d_events for chunk in e['chunks'] if chunk + ) + root_handle_id = int(d_events[0]['meta']['x']) + self.assertGreater(root_handle_id, 0) + + # Decode null-separated entries + root_entries = [e for e in root_listing_payload.split(b'\x00') if e] + # First entry is the unique identifier; remainder are file/dir names + self.assertGreater(len(root_entries), 1, + f'expected entries, got {root_entries}') + entry_names = {e.decode() for e in root_entries[1:]} + self.assertIn('a.txt', entry_names) + self.assertIn('b', entry_names) + + # Find index of 'a.txt' in the entries list (1-based for t=d:y=) + entries_list = [e.decode() for e in root_entries[1:]] + a_idx = entries_list.index('a.txt') + 1 + b_idx = entries_list.index('b') + 1 + + # Read a.txt + parse_bytes(screen, client_dir_read(root_handle_id, a_idx)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + r_events = [e for e in events if e['type'] == 'r'] + a_data = b''.join(e['payload'] for e in r_events if e['payload']) + self.ae(a_data, a_content) + + # Read sub-directory b → should get a new t=d listing + parse_bytes(screen, client_dir_read(root_handle_id, b_idx)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + b_d_events = [e for e in events if e['type'] == 'd'] + self.assertTrue(b_d_events, 'expected t=d listing for b/') + + b_listing_payload = b''.join( + chunk for e in b_d_events for chunk in e['chunks'] if chunk + ) + b_handle_id = int(b_d_events[0]['meta']['x']) + self.assertNotEqual(b_handle_id, root_handle_id) + + b_entries = [e for e in b_listing_payload.split(b'\x00') if e] + b_names = {e.decode() for e in b_entries[1:]} + self.assertIn('c.txt', b_names) + self.assertIn('d', b_names) + + b_entries_list = [e.decode() for e in b_entries[1:]] + bc_idx = b_entries_list.index('c.txt') + 1 + bd_idx = b_entries_list.index('d') + 1 + + # Read b/c.txt (binary integrity) + parse_bytes(screen, client_dir_read(b_handle_id, bc_idx)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + r_events = [e for e in events if e['type'] == 'r'] + bc_data = b''.join(e['payload'] for e in r_events if e['payload']) + self.ae(bc_data, bc_content) + # Check SHA-256 integrity + self.ae(hashlib.sha256(bc_data).digest(), + hashlib.sha256(bc_content).digest()) + + # Read sub-directory b/d → yet another t=d listing + parse_bytes(screen, client_dir_read(b_handle_id, bd_idx)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + bd_d_events = [e for e in events if e['type'] == 'd'] + self.assertTrue(bd_d_events, 'expected t=d listing for b/d/') + + bd_listing_payload = b''.join( + chunk for e in bd_d_events for chunk in e['chunks'] if chunk + ) + bd_handle_id = int(bd_d_events[0]['meta']['x']) + bd_entries = [e for e in bd_listing_payload.split(b'\x00') if e] + bd_names = {e.decode() for e in bd_entries[1:]} + self.assertIn('e.txt', bd_names) + + bd_entries_list = [e.decode() for e in bd_entries[1:]] + bde_idx = bd_entries_list.index('e.txt') + 1 + + # Read b/d/e.txt + parse_bytes(screen, client_dir_read(bd_handle_id, bde_idx)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + r_events = [e for e in events if e['type'] == 'r'] + bde_data = b''.join(e['payload'] for e in r_events if e['payload']) + self.ae(bde_data, bde_content) + + # Close all directory handles + parse_bytes(screen, client_dir_read(bd_handle_id)) + parse_bytes(screen, client_dir_read(b_handle_id)) + parse_bytes(screen, client_dir_read(root_handle_id)) + # No error output expected from close operations + self._assert_no_output(cap, wid) + + def test_dir_handle_close_and_reuse(self) -> None: + """Closing a directory handle invalidates it; subsequent requests return EINVAL.""" + import os, tempfile + with tempfile.TemporaryDirectory() as root: + open(os.path.join(root, 'f.txt'), 'w').close() + uri_list = f'file://{root}\r\n'.encode() + with dnd_test_window() as (osw, wid, screen, cap): + self._setup_uri_drop(screen, wid, cap, uri_list) + parse_bytes(screen, client_request_uri_data(0)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + d_ev = [e for e in events if e['type'] == 'd'] + self.assertTrue(d_ev) + hid = int(d_ev[0]['meta']['x']) + + # Close the handle + parse_bytes(screen, client_dir_read(hid)) + self._assert_no_output(cap, wid) + + # Now try to read from the closed handle → EINVAL + parse_bytes(screen, client_dir_read(hid, 1)) + events = self._get_events(cap, wid) + self.assertEqual(len(events), 1) + self.ae(events[0]['type'], 'R') + self.ae(events[0]['payload'].strip(), b'EINVAL') + + def test_dir_entry_out_of_bounds_returns_enoent(self) -> None: + """Reading a directory entry with an out-of-range index returns ENOENT.""" + import os, tempfile + with tempfile.TemporaryDirectory() as root: + open(os.path.join(root, 'only.txt'), 'w').close() + uri_list = f'file://{root}\r\n'.encode() + with dnd_test_window() as (osw, wid, screen, cap): + self._setup_uri_drop(screen, wid, cap, uri_list) + parse_bytes(screen, client_request_uri_data(0)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + d_ev = [e for e in events if e['type'] == 'd'] + hid = int(d_ev[0]['meta']['x']) + + # Entry 999 does not exist + parse_bytes(screen, client_dir_read(hid, 999)) + events = self._get_events(cap, wid) + self.assertEqual(len(events), 1) + self.ae(events[0]['type'], 'R') + self.ae(events[0]['payload'].strip(), b'ENOENT') + + def test_dir_unique_identifier_prevents_loops(self) -> None: + """Each directory listing starts with a unique id (dev:inode format).""" + import os, tempfile + with tempfile.TemporaryDirectory() as root: + sub = os.path.join(root, 'sub') + os.mkdir(sub) + uri_list = f'file://{root}\r\n'.encode() + with dnd_test_window() as (osw, wid, screen, cap): + self._setup_uri_drop(screen, wid, cap, uri_list) + parse_bytes(screen, client_request_uri_data(0)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + d_ev = [e for e in events if e['type'] == 'd'] + root_payload = b''.join( + chunk for e in d_ev for chunk in e['chunks'] if chunk + ) + root_handle_id = int(d_ev[0]['meta']['x']) + root_uid = root_payload.split(b'\x00')[0].decode() + # uid must be non-empty and contain a colon (dev:inode) + self.assertIn(':', root_uid, f'uid={root_uid!r}') + + # Get the sub directory listing to compare identifiers + entries = [e.decode() for e in root_payload.split(b'\x00')[1:] if e] + sub_idx = entries.index('sub') + 1 + parse_bytes(screen, client_dir_read(root_handle_id, sub_idx)) + raw = cap.consume(wid) + events = parse_escape_codes_b64(raw) + d_ev2 = [e for e in events if e['type'] == 'd'] + sub_payload = b''.join( + chunk for e in d_ev2 for chunk in e['chunks'] if chunk + ) + sub_uid = sub_payload.split(b'\x00')[0].decode() if sub_payload else '' + self.assertIn(':', sub_uid, f'sub uid={sub_uid!r}') + # Root and sub must have different identifiers + self.assertNotEqual(root_uid, sub_uid) + + def test_window_close_during_transfer_no_leak(self) -> None: + """Closing the window while dir handles are open frees all resources (no crash).""" + import os, tempfile + with tempfile.TemporaryDirectory() as root: + open(os.path.join(root, 'f.txt'), 'w').close() + uri_list = f'file://{root}\r\n'.encode() + # The context manager calls dnd_test_cleanup_fake_window on exit, + # which calls drop_free_data → drop_free_dir_handles. + with dnd_test_window() as (osw, wid, screen, cap): + self._setup_uri_drop(screen, wid, cap, uri_list) + parse_bytes(screen, client_request_uri_data(0)) + cap.consume(wid) + # Intentionally leave the handle open – cleanup happens in __exit__ +