Repository: perry-mitchell/webdav-client (ts)

Issue:
XML entities in filenames are not handled correctly
There is a problem with XML entities within filenames:
Given file `Hello &amp;.txt` and its URL encoding `Hello%20%26amp%3b.txt`.

The *PROPFIND* result looks like this on Apache and SabreDAV:
```xml
<d:response>
    <d:href>/Hello%20%26amp%3b.txt</d:href>
```

The `getDirectoryContents` now decodes the URL encoding (correctly):

https://github.com/perry-mitchell/webdav-client/blob/e41a805af2ed102ce581058c411b69e5b7d94a58/source/operations/directoryContents.ts#L78

So the filename now is `Hello &amp;.txt` like it is supposed to be. But `prepareFileFromProps` now also decodes HTML entities which breaks the filename as it is now `Hello &.txt` which is not the correct filename.

https://github.com/perry-mitchell/webdav-client/blob/e41a805af2ed102ce581058c411b69e5b7d94a58/source/tools/dav.ts#L132

Pull request:
fix: Do not double decode XML entities in filenames
* Resolves #363 

The `href` property is already decoded by `parseXML` meaning all XML entities like `&amp;` were decoded. The href just needs to be url decoded.

This prevents issues were files like `a &amp; b.txt` were returned with the wrong name `a & b.txt`

Ref: https://datatracker.ietf.org/doc/html/rfc4918#section-8.3

Patch:
diff --git a/source/operations/directoryContents.ts b/source/operations/directoryContents.ts
index d41d415..7181b52 100644
--- a/source/operations/directoryContents.ts
+++ b/source/operations/directoryContents.ts
@@ -66,7 +66,7 @@ function getDirectoryFiles(
 
     // Map all items to a consistent output structure (results)
     const nodes = responseItems.map(item => {
-        // HREF is the file path (in full)
+        // HREF is the file path (in full) - The href is already XML entities decoded (e.g. foo&amp;bar is reverted to foo&bar)
         const href = normaliseHREF(item.href);
         // Each item should contain a stat object
         const {
diff --git a/source/tools/dav.ts b/source/tools/dav.ts
index 242ca04..7b3357c 100644
--- a/source/tools/dav.ts
+++ b/source/tools/dav.ts
@@ -112,7 +112,7 @@ export function parseXML(xml: string): Promise<DAVResult> {
 
 export function prepareFileFromProps(
     props: DAVResultResponseProps,
-    rawFilename: string,
+    filename: string,
     isDetailed: boolean = false
 ): FileStat {
     // Last modified time, raw size, item type and mime
@@ -129,7 +129,6 @@ export function prepareFileFromProps(
         typeof resourceType.collection !== "undefined"
             ? "directory"
             : "file";
-    const filename = decodeHTMLEntities(rawFilename);
     const stat: FileStat = {
         filename,
         basename: path.basename(filename),


Test patch:
diff --git a/test/node/operations/getDirectoryContents.spec.ts b/test/node/operations/getDirectoryContents.spec.ts
index 04adf80..b479d84 100644
--- a/test/node/operations/getDirectoryContents.spec.ts
+++ b/test/node/operations/getDirectoryContents.spec.ts
@@ -8,9 +8,11 @@ import {
     createWebDAVClient,
     createWebDAVServer,
     restoreRequests,
+    returnFakeResponse,
     useCustomXmlResponse,
     useRequestSpy
 } from "../../helpers.node.js";
+import { readFileSync } from "fs";
 
 describe("getDirectoryContents", function () {
     beforeEach(async function () {
@@ -105,6 +107,35 @@ describe("getDirectoryContents", function () {
         });
     });
 
+    it("returns correct file results for files with HTML entities in their names", function () {
+        returnFakeResponse(
+            readFileSync(
+                new URL("../../responses/propfind-href-html-entities.xml", import.meta.url)
+            ).toString()
+        );
+
+        return this.client.getDirectoryContents("/files").then(function (contents) {
+            const file = contents.find(function (item) {
+                return item.basename === "&amp;.md";
+            });
+            expect(file).not.to.equal(undefined, "Expected file does not exist");
+            expect(file.filename).to.match(/\/files\/&amp;\.md$/);
+        });
+    });
+
+    it("returns correct file results for files with query in href", function () {
+        returnFakeResponse(
+            readFileSync(
+                new URL("../../responses/propfind-href-with-query.xml", import.meta.url)
+            ).toString()
+        );
+
+        return this.client.getDirectoryContents("/files").then(function (contents) {
+            expect(contents).to.have.length(1);
+            expect(contents[0].filename).to.match(/\/files\/some file\?foo=1&bar=2$/);
+        });
+    });
+
     it("returns the contents of a directory with repetitive naming", function () {
         return this.client.getDirectoryContents("/webdav/server").then(function (contents) {
             expect(contents).to.be.an("array");
diff --git a/test/responses/propfind-href-html-entities.xml b/test/responses/propfind-href-html-entities.xml
new file mode 100644
index 0000000..dc3cfb9
--- /dev/null
+++ b/test/responses/propfind-href-html-entities.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0"?>
+<d:multistatus xmlns:d="DAV:">
+	<d:response>
+		<d:href>http://example.com/files/%26amp%3b.md</d:href>
+		<d:propstat>
+			<d:prop>
+				<d:getcontentlength>0</d:getcontentlength>
+				<d:getcontenttype>text/markdown</d:getcontenttype>
+				<d:getetag>&quot;e9fb0c7d776a0607e2277aad4cc74a08&quot;</d:getetag>
+				<d:getlastmodified>Sun, 04 Feb 2024 14:09:50 GMT</d:getlastmodified>
+				<d:resourcetype/>
+			</d:prop>
+			<d:status>HTTP/1.1 200 OK</d:status>
+		</d:propstat>
+		<d:propstat>
+			<d:prop>
+				<d:quota-available-bytes/>
+			</d:prop>
+			<d:status>HTTP/1.1 404 Not Found</d:status>
+		</d:propstat>
+	</d:response>
+</d:multistatus>
diff --git a/test/responses/propfind-href-with-query.xml b/test/responses/propfind-href-with-query.xml
new file mode 100644
index 0000000..306388b
--- /dev/null
+++ b/test/responses/propfind-href-with-query.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0"?>
+<d:multistatus xmlns:d="DAV:">
+	<d:response>
+		<d:href>/files/some%20file?foo=1&amp;bar=2</d:href>
+		<d:propstat>
+			<d:prop>
+				<d:getcontentlength>0</d:getcontentlength>
+				<d:getcontenttype>text/markdown</d:getcontenttype>
+				<d:getetag>&quot;e9fb0c7d776a0607e2277aad4cc74a08&quot;</d:getetag>
+				<d:getlastmodified>Sun, 04 Feb 2024 14:09:50 GMT</d:getlastmodified>
+				<d:resourcetype/>
+			</d:prop>
+			<d:status>HTTP/1.1 200 OK</d:status>
+		</d:propstat>
+	</d:response>
+</d:multistatus>