diff --git a/gdb/NEWS b/gdb/NEWS
index 4dcc344b072..1b5e1e89534 100644
--- a/gdb/NEWS
+++ b/gdb/NEWS
@@ -182,6 +182,11 @@ binary-upload in qSupported reply
stub doesn't report this feature supported, then GDB will not use
the 'x' packet.
+vFile:lstat
+ Return information about files on the remote system. Like
+ vFile:stat but if the filename is a symbolic link, return
+ information about the link itself, the file the link refers to.
+
* Changed remote packets
qXfer:threads:read
diff --git a/gdb/doc/gdb.texinfo b/gdb/doc/gdb.texinfo
index c41aa89a3c4..a564307053e 100644
--- a/gdb/doc/gdb.texinfo
+++ b/gdb/doc/gdb.texinfo
@@ -24782,6 +24782,10 @@ future connections is shown. The available settings are:
@tab @code{vFile:stat}
@tab Host I/O
+@item @code{hostio-lstat-packet}
+@tab @code{vFile:lstat}
+@tab Host I/O
+
@item @code{hostio-setfs-packet}
@tab @code{vFile:setfs}
@tab Host I/O
@@ -46732,6 +46736,17 @@ and the return value is the size of this attachment in bytes.
If an error occurs the return value is -1. The format of the
returned binary attachment is as described in @ref{struct stat}.
+@item vFile:lstat: @var{filename}
+Get information about the file @var{filename} on the target as if from
+an @samp{lstat} call. On success the information is returned as a
+binary attachment and the return value is the size of this attachment
+in bytes. If an error occurs the return value is -1. The format of
+the returned binary attachment is as described in @ref{struct stat}.
+
+If @var{filename} is a symbolic link, then this packet returns
+information about the link itself, not the file that the link refers
+to, this is inline with the @samp{lstat} library call.
+
@item vFile:unlink: @var{filename}
Delete the file at @var{filename} on the target. Return 0,
or -1 if an error occurs. The @var{filename} is a string.
diff --git a/gdb/remote.c b/gdb/remote.c
index 92d428c63d0..8c3040f57ab 100644
--- a/gdb/remote.c
+++ b/gdb/remote.c
@@ -251,6 +251,7 @@ enum {
PACKET_vFile_readlink,
PACKET_vFile_fstat,
PACKET_vFile_stat,
+ PACKET_vFile_lstat,
PACKET_qXfer_auxv,
PACKET_qXfer_features,
PACKET_qXfer_exec_file,
@@ -13242,14 +13243,14 @@ remote_target::fileio_lstat (struct inferior *inf, const char *filename,
if (remote_hostio_set_filesystem (inf, remote_errno) != 0)
return {};
- remote_buffer_add_string (&p, &left, "vFile:stat:");
+ remote_buffer_add_string (&p, &left, "vFile:lstat:");
remote_buffer_add_bytes (&p, &left, (const gdb_byte *) filename,
strlen (filename));
int attachment_len;
const char *attachment;
- int ret = remote_hostio_send_command (p - rs->buf.data (), PACKET_vFile_stat,
+ int ret = remote_hostio_send_command (p - rs->buf.data (), PACKET_vFile_lstat,
remote_errno, &attachment,
&attachment_len);
@@ -16422,6 +16423,8 @@ Show the maximum size of the address (in bits) in a memory packet."), NULL,
add_packet_config_cmd (PACKET_vFile_stat, "vFile:stat", "hostio-stat", 0);
+ add_packet_config_cmd (PACKET_vFile_lstat, "vFile:lstat", "hostio-lstat", 0);
+
add_packet_config_cmd (PACKET_vAttach, "vAttach", "attach", 0);
add_packet_config_cmd (PACKET_vRun, "vRun", "run", 0);
diff --git a/gdb/testsuite/gdb.server/build-id-seqno.exp b/gdb/testsuite/gdb.server/build-id-seqno.exp
index a508a4454b7..8475cccfcc3 100644
--- a/gdb/testsuite/gdb.server/build-id-seqno.exp
+++ b/gdb/testsuite/gdb.server/build-id-seqno.exp
@@ -90,13 +90,13 @@ proc load_binfile_check_debug_is_found { debuginfo_file testname } {
with_test_prefix "$testname" {
with_timeout_factor 5 {
# Probing for .build-id based debug files on remote
- # targets uses the vFile:stat packet by default, though
+ # targets uses the vFile:lstat packet by default, though
# there is a work around that avoids this which can be
# used if GDB is connected to an older gdbserver without
# 'stat' support.
#
# Check the work around works by disabling use of the
- # vFile:stat packet.
+ # vFile:lstat packet.
foreach_with_prefix stat_pkt {auto off} {
clean_restart
@@ -105,7 +105,7 @@ proc load_binfile_check_debug_is_found { debuginfo_file testname } {
gdb_test_no_output "set sysroot target:"
- gdb_test "set remote hostio-stat-packet $stat_pkt"
+ gdb_test "set remote hostio-lstat-packet $stat_pkt"
# Make sure we're disconnected, in case we're testing with an
# extended-remote board, therefore already connected.
diff --git a/gdb/testsuite/gdb.server/fileio-packets.exp b/gdb/testsuite/gdb.server/fileio-packets.exp
new file mode 100644
index 00000000000..d243e93c9e7
--- /dev/null
+++ b/gdb/testsuite/gdb.server/fileio-packets.exp
@@ -0,0 +1,60 @@
+# Copyright 2025 Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# Test some remote file I/O. The associated Python script uses the
+# Python API to create and send vFile:* packets to gdbserver to
+# perform actions like 'stat'. The same action is then performed
+# directly from Python (e.g. a 'stat' is performed), and the results,
+# from gdbserver, and from the local syscall, are compared.
+
+load_lib gdb-python.exp
+load_lib gdbserver-support.exp
+
+require allow_python_tests
+require allow_gdbserver_tests
+require {!is_remote host}
+require {!is_remote target}
+
+standard_testfile
+
+clean_restart
+
+# Make sure we're disconnected, in case we're testing with an
+# extended-remote board, therefore already connected.
+gdb_test "disconnect" ".*"
+
+set pyfile [gdb_remote_download host ${srcdir}/${subdir}/${testfile}.py]
+gdb_test_no_output "source $pyfile" "source the script"
+
+# Start gdbserver, but always in extended-remote mode, and then
+# connect to it from GDB.
+set res [gdbserver_start "--multi --once" ""]
+set gdbserver_protocol "extended-remote"
+set gdbserver_gdbport [lindex $res 1]
+gdb_target_cmd $gdbserver_protocol $gdbserver_gdbport
+
+gdb_test_no_output "set python print-stack full"
+
+set test_file_1 [standard_output_file "test_file_1"]
+remote_exec host "touch $test_file_1"
+
+set test_file_2 [standard_output_file "test_file_2"]
+remote_exec host "ln -s $test_file_1 $test_file_2"
+
+gdb_test "python check_lstat(\"$test_file_1\")" "PASS" \
+ "check remote lstat works on a normal file"
+
+gdb_test "python check_lstat(\"$test_file_2\")" "PASS" \
+ "check remote lstat works on a symbolic link"
diff --git a/gdb/testsuite/gdb.server/fileio-packets.py b/gdb/testsuite/gdb.server/fileio-packets.py
new file mode 100644
index 00000000000..53fb49e8167
--- /dev/null
+++ b/gdb/testsuite/gdb.server/fileio-packets.py
@@ -0,0 +1,170 @@
+# Copyright (C) 2025 Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+import os, stat
+
+
+# Hex encode INPUT_STRING in the same way that GDB does. Each
+# character in INPUT_STRING is expanded to its two digit hex
+# representation in the returned string.
+#
+# Only ASCII characters may appear in INPUT_STRING, this is more
+# restrictive than GDB, but is good enough for testing.
+def hex_encode(input_string):
+ byte_string = input_string.encode("ascii")
+ hex_string = byte_string.hex()
+ return hex_string
+
+
+# Binary remote data packets can contain some escaped bytes. Decode
+# the packet now.
+def unescape_remote_data(buf):
+ escaped = False
+ res = bytearray()
+ for b in buf:
+ if escaped:
+ res.append(b ^ 0x20)
+ escaped = False
+ elif b == ord("}"):
+ escaped = True
+ else:
+ res.append(b)
+ res = bytes(res)
+ return res
+
+
+# Decode the results of a remote stat like command from BUF. Returns
+# None if BUF is not a valid stat result (e.g. if it indicates an
+# error, or the buffer is too short). If BUF is valid then the fields
+# are decoded according to the GDB remote protocol and placed into a
+# dictionary, this dictionary is then returned.
+def decode_stat_reply(buf, byteorder="big"):
+
+ buf = unescape_remote_data(buf)
+
+ if (
+ buf[0] != ord("F")
+ or buf[1] != ord("4")
+ or buf[2] != ord("0")
+ or buf[3] != ord(";")
+ or len(buf) != 68
+ ):
+ l = len(buf)
+ print(f"decode_stat_reply failed: {buf}\t(length = {l})")
+ return None
+
+ # Discard the 'F40;' prefix. The rest is the 64 bytes of data to
+ # be decoded.
+ buf = buf[4:]
+
+ st_dev = int.from_bytes(buf[0:4], byteorder=byteorder)
+ st_ino = int.from_bytes(buf[4:8], byteorder=byteorder)
+ st_mode = int.from_bytes(buf[8:12], byteorder=byteorder)
+ st_nlink = int.from_bytes(buf[12:16], byteorder=byteorder)
+ st_uid = int.from_bytes(buf[16:20], byteorder=byteorder)
+ st_gid = int.from_bytes(buf[20:24], byteorder=byteorder)
+ st_rdev = int.from_bytes(buf[24:28], byteorder=byteorder)
+ st_size = int.from_bytes(buf[28:36], byteorder=byteorder)
+ st_blksize = int.from_bytes(buf[36:44], byteorder=byteorder)
+ st_blocks = int.from_bytes(buf[44:52], byteorder=byteorder)
+ st_atime = int.from_bytes(buf[52:56], byteorder=byteorder)
+ st_mtime = int.from_bytes(buf[56:60], byteorder=byteorder)
+ st_ctime = int.from_bytes(buf[60:64], byteorder=byteorder)
+
+ return {
+ "st_dev": st_dev,
+ "st_ino": st_ino,
+ "st_mode": st_mode,
+ "st_nlink": st_nlink,
+ "st_uid": st_uid,
+ "st_gid": st_gid,
+ "st_rdev": st_rdev,
+ "st_size": st_size,
+ "st_blksize": st_blksize,
+ "st_blocks": st_blocks,
+ "st_atime": st_atime,
+ "st_mtime": st_mtime,
+ "st_ctime": st_ctime,
+ }
+
+
+# Perform an lstat of remote file FILENAME, and create a dictionary of
+# the results, the keys are the fields of the stat structure.
+def remote_lstat(filename):
+ conn = gdb.selected_inferior().connection
+ if not isinstance(conn, gdb.RemoteTargetConnection):
+ raise gdb.GdbError("connection is the wrong type")
+
+ filename_hex = hex_encode(filename)
+ reply = conn.send_packet("vFile:lstat:%s" % filename_hex)
+
+ stat = decode_stat_reply(reply)
+ return stat
+
+
+# Convert a stat_result object to a dictionary that should match the
+# dictionary built from the remote protocol reply.
+def stat_result_to_dict(res):
+ # GDB doesn't support the S_IFLNK flag for the remote protocol, so
+ # clear that flag in the local results.
+ if stat.S_ISLNK(res.st_mode):
+ st_mode = stat.S_IMODE(res.st_mode)
+ else:
+ st_mode = res.st_mode
+
+ # GDB returns an integer for these fields, while Python returns a
+ # floating point value. Convert back to an integer to match GDB.
+ st_atime = int(res.st_atime)
+ st_mtime = int(res.st_mtime)
+ st_ctime = int(res.st_ctime)
+
+ return {
+ "st_dev": res.st_dev,
+ "st_ino": res.st_ino,
+ "st_mode": st_mode,
+ "st_nlink": res.st_nlink,
+ "st_uid": res.st_uid,
+ "st_gid": res.st_gid,
+ "st_rdev": res.st_rdev,
+ "st_size": res.st_size,
+ "st_blksize": res.st_blksize,
+ "st_blocks": res.st_blocks,
+ "st_atime": st_atime,
+ "st_mtime": st_mtime,
+ "st_ctime": st_ctime,
+ }
+
+
+# Perform an lstat of local file FILENAME, and create a dictionary of
+# the results, the keys are the fields of the stat structure.
+def local_lstat(filename):
+ res = os.lstat(filename)
+ return stat_result_to_dict(res)
+
+
+# Perform a remote lstat using GDB, and a local lstat using os.lstat.
+# Compare the results to check they are the same.
+#
+# For this test to work correctly, gdbserver, and GDB (where this
+# Python script is running), must see the same filesystem.
+def check_lstat(filename):
+ s1 = remote_lstat(filename)
+ s2 = local_lstat(filename)
+
+ print(f"s1 = {s1}")
+ print(f"s2 = {s2}")
+
+ assert s1 == s2
+ print("PASS")
diff --git a/gdbserver/hostio.cc b/gdbserver/hostio.cc
index 17b6179d8ca..05f2c4e0b33 100644
--- a/gdbserver/hostio.cc
+++ b/gdbserver/hostio.cc
@@ -522,6 +522,42 @@ handle_stat (char *own_buf, int *new_packet_len)
write_enn (own_buf);
}
+static void
+handle_lstat (char *own_buf, int *new_packet_len)
+{
+ int bytes_sent;
+ char *p;
+ struct stat st;
+ struct fio_stat fst;
+ char filename[HOSTIO_PATH_MAX];
+
+ p = own_buf + strlen ("vFile:lstat:");
+
+ if (require_filename (&p, filename)
+ || require_end (p))
+ {
+ hostio_packet_error (own_buf);
+ return;
+ }
+
+ if (lstat (filename, &st) == -1)
+ {
+ hostio_error (own_buf);
+ return;
+ }
+
+ host_to_fileio_stat (&st, &fst);
+
+ bytes_sent = hostio_reply_with_data (own_buf,
+ (char *) &fst, sizeof (fst),
+ new_packet_len);
+
+ /* If the response does not fit into a single packet, do not attempt
+ to return a partial response, but simply fail. */
+ if (bytes_sent < sizeof (fst))
+ write_enn (own_buf);
+}
+
static void
handle_close (char *own_buf)
{
@@ -641,6 +677,8 @@ handle_vFile (char *own_buf, int packet_len, int *new_packet_len)
handle_fstat (own_buf, new_packet_len);
else if (startswith (own_buf, "vFile:stat:"))
handle_stat (own_buf, new_packet_len);
+ else if (startswith (own_buf, "vFile:lstat:"))
+ handle_lstat (own_buf, new_packet_len);
else if (startswith (own_buf, "vFile:close:"))
handle_close (own_buf);
else if (startswith (own_buf, "vFile:unlink:"))