fix drop_update_mimes: use exact MIME matching and correct sentinel value

Agent-Logs-Url: https://github.com/kovidgoyal/kitty/sessions/c41c0c3f-f9c8-4638-a4c4-64961b030f35

Co-authored-by: kovidgoyal <1308621+kovidgoyal@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-17 16:45:07 +00:00 committed by GitHub
parent 4dd2f54683
commit 0062495e96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 126 additions and 3 deletions

View file

@ -599,6 +599,21 @@ drop_finish(Window *w) {
}
}
/* Return the byte offset of *mime* in the NUL-separated *buf* (length *buf_sz*),
* using exact string comparison. Returns -1 if *mime* is not present.
* strstr() must not be used here because it does substring matching: a MIME
* type like "text/h" would falsely match against "text/html" in the buffer. */
static ssize_t
find_mime_exact_offset(const char *buf, size_t buf_sz, const char *mime) {
const char *p = buf;
const char *end = buf + buf_sz;
while (p < end && *p) {
if (strcmp(p, mime) == 0) return (ssize_t)(p - buf);
p += strlen(p) + 1;
}
return -1;
}
size_t
drop_update_mimes(Window *w, const char **allowed_mimes, size_t allowed_mimes_count) {
if (w->drop.accept_in_progress) return allowed_mimes_count;
@ -607,11 +622,20 @@ drop_update_mimes(Window *w, const char **allowed_mimes, size_t allowed_mimes_co
if (!w->drop.accepted_mimes) return allowed_mimes_count;
RAII_ALLOC(mime_sorter, ms, malloc(sizeof(mime_sorter) * allowed_mimes_count));
if (!ms) return allowed_mimes_count;
const ssize_t sentinel = allowed_mimes_count;
/* Use accepted_mimes_sz + 1 as the sentinel. Every valid byte offset in the
* accepted_mimes buffer is in [0, accepted_mimes_sz - 1], so this value is
* strictly greater than any real offset and guarantees that not-found entries
* sort after all accepted ones. The previous choice of allowed_mimes_count
* was incorrect: when allowed_mimes_count < accepted_mimes_sz (common when
* few MIME types are offered), real byte offsets of accepted entries beyond
* the first would exceed the sentinel and not-found entries would sort into
* the middle of the list, evading the trailing-trim that follows. */
const ssize_t sentinel = (ssize_t)(w->drop.accepted_mimes_sz + 1);
for (size_t i = 0; i < allowed_mimes_count; i++) {
ms[i].m = allowed_mimes[i];
const char *p = strstr(w->drop.accepted_mimes, ms[i].m);
ms[i].key = p ? p - w->drop.accepted_mimes : sentinel;
ssize_t offset = find_mime_exact_offset(
w->drop.accepted_mimes, w->drop.accepted_mimes_sz, ms[i].m);
ms[i].key = offset >= 0 ? offset : sentinel;
}
#define mimes_lt(a, b) ((a)->key < (b)->key)
QSORT(mime_sorter, ms, allowed_mimes_count, mimes_lt);
@ -2697,6 +2721,42 @@ dnd_test_drag_get_data(PyObject *self UNUSED, PyObject *args) {
return ans;
}
/* Test helper: set up accepted-mimes state then call drop_update_mimes.
* Args: window_id (K), operation (i), accepted_mimes space-separated (s),
* offered_mimes (list of str).
* Returns the filtered/prioritised MIME list as a Python list. */
static PyObject*
dnd_test_drop_update_mimes(PyObject *self UNUSED, PyObject *args) {
unsigned long long window_id;
int operation;
const char *accepted;
PyObject *offered_seq;
if (!PyArg_ParseTuple(args, "KisO", &window_id, &operation, &accepted, &offered_seq)) return NULL;
Window *w = window_for_window_id((id_type)window_id);
if (!w) { PyErr_SetString(PyExc_ValueError, "Window not found"); return NULL; }
/* Set up accepted_mimes via drop_set_status (more=false finalises the buffer). */
drop_set_status(w, operation, accepted, strlen(accepted), false);
RAII_PyObject(fast_seq, PySequence_Fast(offered_seq, "offered_mimes must be a sequence"));
if (!fast_seq) return NULL;
Py_ssize_t num = PySequence_Fast_GET_SIZE(fast_seq);
RAII_ALLOC(const char*, mimes, malloc(sizeof(const char*) * (num ? (size_t)num : 1u)));
if (!mimes) return PyErr_NoMemory();
for (Py_ssize_t i = 0; i < num; i++) {
mimes[i] = PyUnicode_AsUTF8(PySequence_Fast_GET_ITEM(fast_seq, i));
if (!mimes[i]) return NULL;
}
size_t count = (size_t)num;
count = drop_update_mimes(w, mimes, count);
PyObject *result = PyList_New((Py_ssize_t)count);
if (!result) return NULL;
for (size_t i = 0; i < count; i++) {
PyObject *s = PyUnicode_FromString(mimes[i]);
if (!s) { Py_DECREF(result); return NULL; }
PyList_SET_ITEM(result, (Py_ssize_t)i, s);
}
return result;
}
static PyMethodDef dnd_methods[] = {
{"dnd_set_test_write_func", (PyCFunction)py_dnd_set_test_write_func, METH_VARARGS, ""},
METHODB(dnd_test_create_fake_window, METH_NOARGS),
@ -2711,6 +2771,7 @@ static PyMethodDef dnd_methods[] = {
METHODB(dnd_test_drag_finish, METH_VARARGS),
METHODB(dnd_test_probe_state, METH_VARARGS),
METHODB(dnd_test_drag_get_data, METH_VARARGS),
METHODB(dnd_test_drop_update_mimes, METH_VARARGS),
{NULL, NULL, 0, NULL}
};

View file

@ -16,6 +16,7 @@
dnd_test_cleanup_fake_window,
dnd_test_create_fake_window,
dnd_test_drag_notify,
dnd_test_drop_update_mimes,
dnd_test_fake_drop_data,
dnd_test_fake_drop_event,
dnd_test_force_drag_dropped,
@ -3162,3 +3163,64 @@ def test_query_works_without_registration(self) -> None:
events = self._get_events(cap)
self.assertEqual(len(events), 1, events)
self.ae(events[0]['type'], 'q')
# ---- drop_update_mimes exact-match tests ----------------------------------------
def test_drop_update_mimes_exact_match_accepted(self) -> None:
"""A MIME type that exactly matches an accepted type is included in the result."""
with dnd_test_window() as (screen, cap):
result = dnd_test_drop_update_mimes(
cap.window_id, 1, 'text/html text/plain',
['text/html', 'text/plain', 'application/octet-stream'],
)
self.assertIn('text/html', result)
self.assertIn('text/plain', result)
# application/octet-stream was not accepted
self.assertNotIn('application/octet-stream', result)
def test_drop_update_mimes_no_false_positive_for_prefix(self) -> None:
"""A MIME type that is a prefix of an accepted type must NOT be accepted.
Previously strstr() was used, which would match 'text/h' inside 'text/html'
and incorrectly report it as accepted.
"""
with dnd_test_window() as (screen, cap):
result = dnd_test_drop_update_mimes(
cap.window_id, 1, 'text/html',
['text/h', 'text/html'],
)
# Only the exact match should survive
self.assertNotIn('text/h', result, "'text/h' is a prefix of 'text/html' and must not be accepted")
self.assertIn('text/html', result)
def test_drop_update_mimes_no_false_positive_for_suffix(self) -> None:
"""A MIME type that is a suffix-match inside the buffer must NOT be accepted."""
with dnd_test_window() as (screen, cap):
# accepted buffer looks like: "text/html\0\0"
# 'html' is a substring but not an entry
result = dnd_test_drop_update_mimes(
cap.window_id, 1, 'text/html',
['html', 'text/html'],
)
self.assertNotIn('html', result, "'html' is a substring of 'text/html' and must not be accepted")
self.assertIn('text/html', result)
def test_drop_update_mimes_priority_order(self) -> None:
"""Accepted MIME types are returned in the order specified by the client."""
with dnd_test_window() as (screen, cap):
# Client accepts text/plain first, then text/html
result = dnd_test_drop_update_mimes(
cap.window_id, 1, 'text/plain text/html',
['text/html', 'text/plain'],
)
self.assertEqual(result, ['text/plain', 'text/html'],
'accepted order (text/plain before text/html) must be preserved')
def test_drop_update_mimes_none_accepted(self) -> None:
"""When no offered MIME type is accepted, the result is empty."""
with dnd_test_window() as (screen, cap):
result = dnd_test_drop_update_mimes(
cap.window_id, 1, 'text/plain',
['application/pdf', 'image/png'],
)
self.assertEqual(result, [])