diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
index 418cdc11..f575e157 100644
--- a/.github/workflows/windows-build.yml
+++ b/.github/workflows/windows-build.yml
@@ -262,11 +262,11 @@ jobs:
# tested nothing: pin the skips, and floor the passes in case the glob empties.
# footer-overflow and purge-longpath skip on Windows (need a path past MAX_PATH);
# crange pending #581;
- # webdav-mime needs a reapable background listener, which MSYS cannot give it;
+ # webdav-default and webdav-mime need a reapable background listener, which MSYS cannot give them;
# badmtime needs a filesystem that stores an mtime past gmtime's range;
# single-file ends on a GUI half needing htsserver, which this job does not build;
# update-304-leak needs a LeakSanitizer build, which MSVC has no equivalent of.
- expected_skips=" 01_engine-footer-overflow.test 100_local-purge-longpath.test 114_local-update-304-leak.test 48_local-crange-memresume.test 71_local-crange-repaircache.test 79_local-proxytrack-webdav-mime.test 88_local-proxytrack-badmtime.test 94_local-single-file.test"
+ expected_skips=" 01_engine-footer-overflow.test 100_local-purge-longpath.test 114_local-update-304-leak.test 120_local-proxytrack-webdav-default.test 48_local-crange-memresume.test 71_local-crange-repaircache.test 79_local-proxytrack-webdav-mime.test 88_local-proxytrack-badmtime.test 94_local-single-file.test"
# First, or the deadline reads as an unexplained shortfall in the gates below.
[ "$deadline" -eq 0 ] || { echo "::error::suite did not finish within ${suite_deadline}s"; exit 1; }
[ "$pass" -ge 90 ] || { echo "::error::only $pass tests passed ($skip skipped)"; exit 1; }
diff --git a/src/proxy/proxytrack.c b/src/proxy/proxytrack.c
index c34afbf8..e74feddb 100644
--- a/src/proxy/proxytrack.c
+++ b/src/proxy/proxytrack.c
@@ -748,7 +748,8 @@ static PT_Element proxytrack_process_DAV_Request(PT_Indexes indexes,
const char *thisUrl = list[i];
const char *mimeType = "application/octet-stream";
unsigned int thisUrlLen = (unsigned int) strlen(thisUrl);
- int thisIsDir = (thisUrl[thisUrlLen - 1] == '/') ? 1 : 0;
+ /* the folder's default document is enumerated as an empty name */
+ int thisIsDir = (hts_lastchar(thisUrl) == '/') ? 1 : 0;
/* Item URL */
StringRoom(itemUrl,
@@ -853,8 +854,7 @@ static PT_Element proxytrack_process_HTTP_List(PT_Indexes indexes,
for(isDir = 1; isDir >= 0; isDir--) {
for(i = 0; list[i] != NULL; i++) {
char *thisUrl = list[i];
- unsigned int thisUrlLen = (unsigned int) strlen(thisUrl);
- int thisIsDir = (thisUrl[thisUrlLen - 1] == '/') ? 1 : 0;
+ int thisIsDir = (hts_lastchar(thisUrl) == '/') ? 1 : 0;
if (thisIsDir == isDir) {
if (isDir)
diff --git a/tests/120_local-proxytrack-webdav-default.test b/tests/120_local-proxytrack-webdav-default.test
new file mode 100755
index 00000000..f36f9791
--- /dev/null
+++ b/tests/120_local-proxytrack-webdav-default.test
@@ -0,0 +1,121 @@
+#!/bin/bash
+#
+# A PROPFIND whose path is an exact cache entry enumerates that entry's default
+# document under an empty name; proxytrack used to index one byte before it and
+# die on the whole listener.
+
+set -euo pipefail
+
+: "${top_srcdir:=..}"
+# shellcheck source=tests/testlib.sh
+. "$top_srcdir/tests/testlib.sh"
+
+python=$(find_python) || {
+ echo "python3 missing, skipping"
+ exit 77
+}
+command -v curl >/dev/null 2>&1 || {
+ echo "curl missing, skipping"
+ exit 77
+}
+# MSYS cannot reap a native listener, and the orphan wedges the suite (#595).
+if is_windows; then
+ echo "windows: cannot reap a backgrounded proxytrack, skipping"
+ exit 77
+fi
+
+dir=$(mktemp -d)
+ptpid=
+cleanup() {
+ stop_server "$ptpid"
+ rm -rf "$dir"
+}
+trap 'set +e; cleanup' EXIT
+trap 'set +e; cleanup; exit 1' INT TERM HUP
+
+printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\n' >"$dir/hdr"
+printf 'hello' >"$dir/body"
+alen=$(($(wc -c <"$dir/hdr") + $(wc -c <"$dir/body")))
+{
+ printf 'filedesc://t.arc 0.0.0.0 20250101000000 text/plain 200 - - 0 t.arc 9\n'
+ printf '2 0 test\n'
+ printf '\n\n'
+ printf 'http://example.com/page.html 0.0.0.0 20250101000000 text/html 200 - - 0 t.arc %d\n' "$alen"
+ cat "$dir/hdr" "$dir/body"
+} >"$dir/in.arc"
+
+freeport() {
+ "$python" -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()'
+}
+proxyport=$(freeport)
+icpport=$(freeport)
+
+proxytrack "127.0.0.1:$proxyport" "127.0.0.1:$icpport" "$dir/in.arc" >"$dir/pt.log" 2>&1 &
+ptpid=$!
+waited=0
+until grep -qE "HTTP Proxy installed on|Unable to (initialize a temporary server|create the server)" "$dir/pt.log"; do
+ kill -0 "$ptpid" 2>/dev/null || {
+ echo "FAIL: proxytrack exited before listening"
+ cat "$dir/pt.log"
+ exit 1
+ }
+ test "$waited" -lt 50 || {
+ echo "FAIL: proxytrack never announced its listen port"
+ exit 1
+ }
+ sleep 0.1
+ waited=$((waited + 1))
+done
+grep -q "HTTP Proxy installed on" "$dir/pt.log" || {
+ echo "FAIL: proxytrack failed to bind"
+ cat "$dir/pt.log"
+ exit 1
+}
+
+propfind() {
+ curl -s -i --max-time 30 -X PROPFIND -H "Depth: 1" \
+ "http://127.0.0.1:$proxyport/webdav/example.com/$1"
+}
+
+resp=$(propfind page.html) || {
+ echo "FAIL: PROPFIND on an exact cache entry did not answer"
+ cat "$dir/pt.log"
+ exit 1
+}
+grep -q '^HTTP/1\.[01] 207 ' <<<"$resp" || {
+ echo "FAIL: expected 207 Multi-Status"
+ printf '%s\n' "$resp"
+ exit 1
+}
+grep -q 'Default Document for the Folder' <<<"$resp" || {
+ echo "FAIL: the default document of the entry was not listed"
+ printf '%s\n' "$resp"
+ exit 1
+}
+# The name alone would still pass on a truncated or garbage URL, so pin the
+# href: the default document is the collection path plus an empty name.
+grep -q '/webdav/example\.com/page\.html/' <<<"$resp" || {
+ echo "FAIL: the default document was listed under the wrong href"
+ printf '%s\n' "$resp"
+ exit 1
+}
+responses=$(grep -c '