diff --git a/packages/core/src/sync/webdav-client.test.ts b/packages/core/src/sync/webdav-client.test.ts index 63afacff..44031d78 100644 --- a/packages/core/src/sync/webdav-client.test.ts +++ b/packages/core/src/sync/webdav-client.test.ts @@ -110,6 +110,51 @@ describe("WebDavClient PROPFIND parsing", () => { ]); }); + it("treats MKCOL auth failure as success when the parent listing shows the directory", async () => { + const calls: { method: string; url: string }[] = []; + installFetchStub((url, options) => { + const method = String(options?.method ?? "GET"); + calls.push({ method, url }); + + if (method === "PROPFIND" && url.endsWith("/readany/")) { + return new Response("", { status: 404 }); + } + if (method === "MKCOL") { + return new Response("", { status: 401 }); + } + return new Response( + ` + + + /dav/ + + + + /dav/readany/ + + + `, + { status: 207 }, + ); + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const client = new WebDavClient("https://dav.example.com/dav", "alice", "secret"); + await client.ensureDirectory("/readany"); + } finally { + warnSpy.mockRestore(); + } + + expect(calls.map((call) => call.method)).toEqual(["PROPFIND", "MKCOL", "PROPFIND", "PROPFIND"]); + expect(calls.map((call) => call.url)).toEqual([ + "https://dav.example.com/dav/readany/", + "https://dav.example.com/dav/readany/", + "https://dav.example.com/dav/readany/", + "https://dav.example.com/dav/", + ]); + }); + it("uses a collection path when safely reading a directory", async () => { const calls: { method: string; url: string }[] = []; installFetchStub((url, options) => { diff --git a/packages/core/src/sync/webdav-client.ts b/packages/core/src/sync/webdav-client.ts index e39ce90b..47bddbf3 100644 --- a/packages/core/src/sync/webdav-client.ts +++ b/packages/core/src/sync/webdav-client.ts @@ -93,6 +93,17 @@ function toCollectionPath(path: string): string { return path.endsWith("/") ? path : `${path}/`; } +function getParentCollectionPath(path: string): { parent: string; name: string } | null { + const collectionPath = toCollectionPath(path); + if (collectionPath === "/") return null; + + const trimmed = collectionPath.replace(/\/+$/g, ""); + const lastSlash = trimmed.lastIndexOf("/"); + const name = trimmed.slice(lastSlash + 1); + const parent = lastSlash <= 0 ? "/" : trimmed.slice(0, lastSlash); + return { parent: toCollectionPath(parent), name }; +} + function createHttpWebDavError( status: number, statusText: string | undefined, @@ -407,7 +418,7 @@ export class WebDavClient { try { resp = await this.request("MKCOL", collectionPath); } catch (e) { - if (await this.propfindExists(collectionPath, { timeoutMs: DIRECTORY_PROBE_TIMEOUT_MS })) { + if (await this.collectionExists(collectionPath)) { console.warn(`[WebDAV] MKCOL ${path} failed but directory exists; continuing`); return; } @@ -420,6 +431,10 @@ export class WebDavClient { if (status === 405 || status === 409) { return; } + if ((status === 401 || status === 403) && (await this.collectionExists(collectionPath))) { + console.warn(`[WebDAV] MKCOL ${path} returned ${status} but directory exists; continuing`); + return; + } throw new Error(`WebDAV MKCOL failed for ${path}: ${status} ${resp.statusText || ""}`); } @@ -661,6 +676,27 @@ export class WebDavClient { } } + private async parentListingContainsCollection(path: string): Promise { + const parentInfo = getParentCollectionPath(path); + if (!parentInfo) return true; + + try { + const resources = await this.propfind(parentInfo.parent); + return resources.some( + (resource) => resource.isCollection && resource.name === parentInfo.name, + ); + } catch { + return false; + } + } + + private async collectionExists(path: string): Promise { + if (await this.propfindExists(path, { timeoutMs: DIRECTORY_PROBE_TIMEOUT_MS })) { + return true; + } + return this.parentListingContainsCollection(path); + } + /** List directory contents (PROPFIND Depth 1) */ async propfind(path: string): Promise { const resp = await this.request("PROPFIND", path, {