From c7c42afab91c46e6baaa0e2801626e13acd6c30a Mon Sep 17 00:00:00 2001
From: hunterinvariants <217359725+hunterinvariants@users.noreply.github.com>
Date: Thu, 13 Aug 2026 21:14:02 +0200
Subject: [PATCH] file: support directory listing on Windows
Closes #22577
---
docs/TODO.md | 7 ---
lib/curlx/fopen.c | 31 ++++++++++++
lib/curlx/fopen.h | 2 +
lib/file.c | 113 +++++++++++++++++++++++++++++++++++++++++---
tests/data/test3203 | 14 ++++--
5 files changed, 150 insertions(+), 17 deletions(-)
diff --git a/docs/TODO.md b/docs/TODO.md
index 6203056994..6829910101 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -438,13 +438,6 @@ use text frames.
An abandoned attempt to add support for this exists in [PR
22093](https://github.com/curl/curl/pull/22093).
-# FILE
-
-## Directory listing on non-POSIX
-
-Listing the contents of a directory accessed with FILE only works on platforms
-with `opendir()`. Support could be added for more systems, like Windows.
-
# TLS
## `TLS-PSK` with OpenSSL
diff --git a/lib/curlx/fopen.c b/lib/curlx/fopen.c
index 30754b310a..09264aeb10 100644
--- a/lib/curlx/fopen.c
+++ b/lib/curlx/fopen.c
@@ -291,6 +291,37 @@ HANDLE curlx_CreateFile(const char *filename,
return handle;
}
+
+HANDLE curlx_FindFirstFile(const char *filename,
+ WIN32_FIND_DATA *find_data)
+{
+ HANDLE handle = INVALID_HANDLE_VALUE;
+
+#ifdef UNICODE
+ TCHAR *filename_t = curlx_convert_UTF8_to_wchar(filename);
+#else
+ const TCHAR *filename_t = filename;
+#endif
+
+ if(filename_t) {
+ TCHAR *fixed = NULL;
+ const TCHAR *target;
+
+ if(fix_excessive_path(filename_t, &fixed))
+ target = fixed;
+ else
+ target = filename_t;
+
+ handle = FindFirstFile(target, find_data);
+ CURLX_FREE(fixed);
+
+#ifdef UNICODE
+ curlx_free(filename_t);
+#endif
+ }
+
+ return handle;
+}
#endif /* !CURL_WINDOWS_UWP */
int curlx_win32_open(const char *filename, int oflag, ...)
diff --git a/lib/curlx/fopen.h b/lib/curlx/fopen.h
index b64fbf6514..469d3c8813 100644
--- a/lib/curlx/fopen.h
+++ b/lib/curlx/fopen.h
@@ -43,6 +43,8 @@ HANDLE curlx_CreateFile(const char *filename,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile);
+HANDLE curlx_FindFirstFile(const char *filename,
+ WIN32_FIND_DATA *find_data);
#endif /* !CURL_WINDOWS_UWP */
#define curlx_fstat _fstati64
#define curlx_struct_stat struct _stati64
diff --git a/lib/file.c b/lib/file.c
index 2ebfd0c67b..d3553b7df2 100644
--- a/lib/file.c
+++ b/lib/file.c
@@ -76,12 +76,14 @@ struct FILEPROTO {
char *freepath; /* pointer to the allocated block we must free, this might
differ from the 'path' pointer */
int fd; /* open file descriptor to read from! */
+ bool is_dir;
};
static void file_cleanup(struct FILEPROTO *file)
{
curlx_safefree(file->freepath);
file->path = NULL;
+ file->is_dir = FALSE;
if(file->fd != -1) {
curlx_close(file->fd);
file->fd = -1;
@@ -126,6 +128,38 @@ static CURLcode file_done(struct Curl_easy *data,
return CURLE_OK;
}
+static int file_stat(const char *path, curlx_struct_stat *statbuf)
+{
+#ifdef _WIN32
+ int result = curlx_stat(path, statbuf);
+
+ if(result) {
+ size_t pathlen = strlen(path);
+
+ /* MSVCRT's narrow stat() rejects trailing directory separators. */
+ if((pathlen > 3) &&
+ ((path[pathlen - 1] == '\\') || (path[pathlen - 1] == '/'))) {
+ char *trimmed = curlx_strdup(path);
+
+ if(trimmed) {
+ do {
+ trimmed[--pathlen] = '\0';
+ } while((pathlen > 3) &&
+ ((trimmed[pathlen - 1] == '\\') ||
+ (trimmed[pathlen - 1] == '/')));
+
+ result = curlx_stat(trimmed, statbuf);
+ curlx_free(trimmed);
+ }
+ }
+ }
+
+ return result;
+#else
+ return curlx_stat(path, statbuf);
+#endif
+}
+
/*
* file_connect() gets called from Curl_protocol_connect() to allow us to
* do protocol-specific actions at connect-time. We emulate a
@@ -135,6 +169,9 @@ static CURLcode file_connect(struct Curl_easy *data, bool *done)
{
char *real_path;
struct FILEPROTO *file = Curl_meta_get(data, CURL_META_FILE_EASY);
+#ifdef _WIN32
+ curlx_struct_stat statbuf;
+#endif
int fd;
#ifdef DOS_FILESYSTEM
size_t i;
@@ -233,7 +270,13 @@ static CURLcode file_connect(struct Curl_easy *data, bool *done)
file->freepath = real_path; /* free this when done */
file->fd = fd;
- if(!data->state.upload && (fd == -1)) {
+#ifdef _WIN32
+ if(!data->state.upload && (fd == -1) &&
+ !file_stat(file->path, &statbuf) && S_ISDIR(statbuf.st_mode))
+ file->is_dir = TRUE;
+#endif
+
+ if(!data->state.upload && (fd == -1) && !file->is_dir) {
failf(data, "Could not open file %s", data->state.up.path);
file_done(data, CURLE_FILE_COULDNT_READ_FILE, FALSE);
return CURLE_FILE_COULDNT_READ_FILE;
@@ -370,6 +413,58 @@ out:
return result;
}
+#if defined(_WIN32) && !defined(CURL_WINDOWS_UWP)
+static CURLcode win32_file_list(struct Curl_easy *data, const char *path)
+{
+ WIN32_FIND_DATA entry;
+ HANDLE handle;
+ char *pattern;
+ size_t pathlen = strlen(path);
+ CURLcode result = CURLE_OK;
+ DWORD error;
+
+ pattern = curl_maprintf("%s%s*", path,
+ pathlen &&
+ (path[pathlen - 1] == '\\' ||
+ path[pathlen - 1] == '/') ? "" : "\\");
+ if(!pattern)
+ return CURLE_OUT_OF_MEMORY;
+
+ handle = curlx_FindFirstFile(pattern, &entry);
+ curlx_free(pattern);
+ if(handle == INVALID_HANDLE_VALUE)
+ return CURLE_READ_ERROR;
+
+ do {
+ if(entry.cFileName[0] != TEXT('.')) {
+ char *name = curlx_convert_tchar_to_UTF8(entry.cFileName);
+
+ if(!name) {
+ result = CURLE_OUT_OF_MEMORY;
+ break;
+ }
+
+ result = Curl_client_write(data, CLIENTWRITE_BODY,
+ name, strlen(name));
+ curlx_free(name);
+ if(result)
+ break;
+
+ result = Curl_client_write(data, CLIENTWRITE_BODY, "\n", 1);
+ if(result)
+ break;
+ }
+ } while(FindNextFile(handle, &entry));
+
+ error = GetLastError();
+ if(!result && error != ERROR_NO_MORE_FILES)
+ result = CURLE_READ_ERROR;
+
+ FindClose(handle);
+ return result;
+}
+#endif
+
/*
* file_do() is the protocol-specific function for the do-phase, separated
* from the connect-phase above. Other protocols merely setup the transfer in
@@ -405,8 +500,10 @@ static CURLcode file_do(struct Curl_easy *data, bool *done)
fd = file->fd;
/* VMS: This only works reliable for STREAMLF files */
- if(curlx_fstat(fd, &statbuf) != -1) {
- if(!S_ISDIR(statbuf.st_mode))
+ if((file->is_dir ? file_stat(file->path, &statbuf) :
+ curlx_fstat(fd, &statbuf)) != -1) {
+ file->is_dir = (fd == -1) || S_ISDIR(statbuf.st_mode);
+ if(!file->is_dir)
expected_size = statbuf.st_size;
/* and store the modification time */
data->info.filetime = statbuf.st_mtime;
@@ -510,7 +607,7 @@ static CURLcode file_do(struct Curl_easy *data, bool *done)
Curl_pgrsSetDownloadSize(data, expected_size);
if(data->state.resume_from) {
- if(!S_ISDIR(statbuf.st_mode)) {
+ if(!file->is_dir) {
if(data->state.resume_from !=
curl_lseek(fd, data->state.resume_from, SEEK_SET))
return CURLE_BAD_DOWNLOAD_RESUME;
@@ -524,7 +621,7 @@ static CURLcode file_do(struct Curl_easy *data, bool *done)
if(result)
goto out;
- if(!S_ISDIR(statbuf.st_mode)) {
+ if(!file->is_dir) {
while(!result) {
ssize_t nread;
/* Do not fill a whole buffer if we want less than all data */
@@ -558,7 +655,11 @@ static CURLcode file_do(struct Curl_easy *data, bool *done)
}
}
else {
-#ifdef HAVE_OPENDIR
+#if defined(_WIN32) && !defined(CURL_WINDOWS_UWP)
+ result = win32_file_list(data, file->path);
+ if(result)
+ goto out;
+#elif defined(HAVE_OPENDIR)
DIR *dir = opendir(file->path);
struct dirent *entry;
diff --git a/tests/data/test3203 b/tests/data/test3203
index 401f697419..8b52cf41cc 100644
--- a/tests/data/test3203
+++ b/tests/data/test3203
@@ -14,16 +14,18 @@ file
Get a directory using file://
-
-
-!win32
-
file://localhost%FILE_PWD/%LOGDIR/test%TESTNUMBER.dir/
+%if Unicode
+
+Contents of file are irrelevant
+
+%else
Contents of file are irrelevant
+%endif
# Verify data after the test has been "shot"
@@ -32,7 +34,11 @@ Contents of file are irrelevant
0
+%if Unicode
+%hex[%E6%BC%A2%E5%AD%97%2D%C3%A4]hex%.txt
+%else
dir-listing-test.txt
+%endif