fix: webdav not working with new Windows Explorer
Massimo Melina committed
May 4, 2026 at 00:40 UTC
5ac041da23ded2bb530fa2289c845327d40344a0
2 files changed
+311
-85
src/webdav.ts
+211
-85
@@ -1,41 +1,23 @@
1
import Koa from 'koa'
2
import { text as stream2string } from 'node:stream/consumers'
3
import {
4
- getNodeName, nodeIsFolder, nodeIsLink, nodeStats, statusCodeForMissingPerm, urlToNode, vfs, VfsNode, walkNode
4
+ getNodeName, nodeIsFolder, nodeIsLink, nodeStats, statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
5
} from './vfs'
6
import {
7
- HTTP_BAD_REQUEST,
8
- HTTP_CONFLICT,
9
- HTTP_CREATED,
10
- HTTP_METHOD_NOT_ALLOWED,
11
- HTTP_NO_CONTENT,
12
- HTTP_NOT_FOUND,
13
- HTTP_OK,
14
- HTTP_PRECONDITION_FAILED,
15
- HTTP_SERVER_ERROR,
16
- HTTP_UNAUTHORIZED,
17
- HTTP_LOCKED,
18
- HTTP_FORBIDDEN,
19
- DAY,
20
- CFG,
21
- enforceFinal,
22
- pathEncode,
23
- prefix,
24
- getOrSet,
25
- Dict,
26
- Timeout,
27
- join as crossJoin,
28
- try_,
29
- safeDecodeURIComponent,
30
- pathDecode
7
+ HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_CREATED, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_OK,
8
+ HTTP_PRECONDITION_FAILED, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED, HTTP_LOCKED, HTTP_FORBIDDEN, HTTP_MESSAGES,
9
+ DAY, CFG, enforceFinal, removeFinal, pathEncode, prefix, getOrSet, Dict, Timeout, join as crossJoin, try_,
10
+ safeDecodeURIComponent, wantArray,
11
} from './cross'
12
import { PassThrough } from 'stream'
33
-import { mkdir, rm } from 'fs/promises'
13
+import { mkdir, rm, utimes } from 'fs/promises'
14
+import { STATUS_CODES } from 'http'
15
import { isValidFileName } from './misc'
16
import { basename, dirname, join } from 'path'
17
import { moveFiles, requestedRename } from './frontEndApis'
18
import { randomUUID } from 'node:crypto'
38
-import { IS_MAC } from './const'
19
+import { IS_MAC, IS_WINDOWS } from './const'
20
+import fswin from 'fswin'
21
import { exec } from 'child_process'
22
import { getCurrentUsername } from './auth'
23
import { defineConfig } from './config'
@@ -49,15 +31,29 @@ const webdavPrompted = expiringCache<boolean>(DAY)
31
const webdavDetectedAgents = expiringCache<boolean>(DAY)
32
33
const TOKEN_HEADER = 'lock-token'
52
-const WEBDAV_METHODS = new Set(['PROPFIND', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK'])
34
+const WEBDAV_METHODS = new Set(['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK'])
35
const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length']
36
const KNOWN_UA = /webdav|miniredir|davclnt/i
37
const LOCK_DEFAULT_SECONDS = 3600
38
const LOCK_MAX_SECONDS = DAY / 1000
39
const xmlParser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true })
40
+const PROPPATCH_PROTECTED_LIVE_PROPS = new Set([
41
+ 'creationdate', 'displayname', 'getcontentlanguage', 'getcontentlength', 'getcontenttype',
42
+ 'getetag', 'getlastmodified', 'lockdiscovery', 'resourcetype', 'supportedlock',
43
+])
44
+const PROPPATCH_UTIME_PROPS = new Set(['win32lastmodifiedtime', 'win32lastaccesstime'])
45
+const WINDOWS_FILE_ATTRIBUTE_FLAGS = {
46
+ IS_READ_ONLY: 0x1,
47
+ IS_HIDDEN: 0x2,
48
+ IS_SYSTEM: 0x4,
49
+ IS_ARCHIVED: 0x20,
50
+ IS_TEMPORARY: 0x100,
51
+ IS_OFFLINE: 0x1000,
52
+ IS_NOT_CONTENT_INDEXED: 0x2000,
53
+} as const
54
55
const canOverwrite = new Set<string>()
60
-const locks = new Map<string, { token: string, timeout: NodeJS.Timeout, seconds: number, principal: string }>()
56
+const locks = new Map<string, { token: string, timeout: NodeJS.Timeout, seconds: number, username: string }>()
57
58
export function releaseWebdavLock(path: string) {
59
const lock = locks.get(path)
@@ -77,7 +73,7 @@ async function isLocked(path: string, ctx: Koa.Context) {
73
}
74
const ifHeader = ctx.get('If')
75
const tokenHeader = ctx.get(TOKEN_HEADER)
80
- if (isSameLockPrincipal(lock, ctx) && (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token)))
76
+ if (isSameLockUsername(lock, ctx) && (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token)))
77
return false
78
ctx.status = HTTP_LOCKED
79
return true
@@ -88,12 +84,12 @@ function hasToken(header: string, token: string) {
84
return header.includes(`<${token}>`) || header.split(/[,;\s]+/).includes(token)
85
}
86
91
-function getWebdavPrincipal(ctx: Koa.Context) {
87
+function getWebdavUsername(ctx: Koa.Context) {
88
return getCurrentUsername(ctx) || ''
89
}
90
95
-function isSameLockPrincipal(lock: { principal: string }, ctx: Koa.Context) {
96
- return lock.principal === getWebdavPrincipal(ctx)
91
+function isSameLockUsername(lock: { username: string }, ctx: Koa.Context) {
92
+ return lock.username === getWebdavUsername(ctx)
93
}
94
95
export const webdav: Koa.Middleware = async (ctx, next) => {
@@ -112,24 +108,36 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
108
if (isWebdavAuthRequest && ua && getCurrentUsername(ctx))
109
webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true)
110
115
- if (ctx.method === 'OPTIONS') {
111
+ if (ctx.method === 'OPTIONS')
112
+ return handleOptions()
113
+ if (isWebdavAuthRequest && shouldChallengeWebdav())
114
+ return
115
+ switch (ctx.method) {
116
+ case 'PUT': return handlePut()
117
+ case 'MKCOL': return handleMkcol()
118
+ case 'MOVE': return handleMove()
119
+ case 'DELETE': return handleDelete()
120
+ case 'UNLOCK': return handleUnlock()
121
+ case 'LOCK': return handleLock()
122
+ case 'PROPFIND': return handlePropfind()
123
+ case 'PROPPATCH': return handleProppatch()
124
+ }
125
+ return next()
126
+
127
+ async function handleOptions() {
128
if (ctx.get('Access-Control-Request-Method')) // it's a preflight cors request, not webdav
129
return next()
130
setWebdavHeaders()
131
ctx.body = ''
120
- return
132
}
122
- if (isWebdavAuthRequest && shouldChallengeWebdav())
123
- return
124
- if (ctx.method === 'PUT') {
133
+
134
+ async function handlePut() {
135
if (await isLocked(path, ctx)) return
136
const overwriteGraceKey = path + prefix('|', getCurrentUsername(ctx)) // bind temporary overwrite grace to the authenticated user so accounts cannot reuse each other's grace window
137
// Finder first creates an empty file (a test?) then wants to overwrite it, which requires deletion permission, but the user may not have it, causing a renamed upload. To solve, so we give it special permission for a few seconds.
138
const x = ctx.get('x-expected-entity-length') // field used by Finder's webdav on actual upload, after
129
- if (!x && !ctx.length) {
130
- canOverwrite.add(overwriteGraceKey)
131
- setTimeout(() => canOverwrite.delete(overwriteGraceKey), 10_000) // grace period
132
- }
139
+ if (!x && !ctx.length)
140
+ allowWebdavOverwrite(overwriteGraceKey)
141
else if (canOverwrite.has(overwriteGraceKey)) {
142
canOverwrite.delete(overwriteGraceKey)
143
const node = await urlToNode(path, ctx)
@@ -141,9 +149,12 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
149
150
if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua)))
151
ctx.query.existing ??= 'overwrite' // with webdav this is our default
144
- return next()
152
+ await next()
153
+ if (ctx.status === HTTP_OK)
154
+ allowWebdavOverwrite(overwriteGraceKey)
155
}
146
- if (ctx.method === 'MKCOL') {
156
+
157
+ async function handleMkcol() {
158
setWebdavHeaders()
159
if (await isLocked(path, ctx)) return
160
const node = await urlToNode(path, ctx)
@@ -170,7 +181,8 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
181
return ctx.status = HTTP_SERVER_ERROR
182
}
183
}
173
- if (ctx.method === 'MOVE') {
184
+
185
+ async function handleMove() {
186
setWebdavHeaders()
187
if (await isLocked(path, ctx)) return
188
const node = await urlToNode(path, ctx)
@@ -202,22 +214,23 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
214
releaseWebdavLock(path) // successful MOVE leaves the old path invalid, therefore its lock must be dropped
215
return ctx.status = !err ? HTTP_CREATED : typeof err === 'number' ? err : HTTP_SERVER_ERROR
216
}
205
- if (ctx.method === 'DELETE') {
217
+
218
+ async function handleDelete() {
219
setWebdavHeaders()
220
if (await isLocked(path, ctx)) return
221
await next()
222
if (ctx.status === HTTP_OK)
223
releaseWebdavLock(path) // webdav clients may forget UNLOCK; successful delete must clear any lock
211
- return
224
}
213
- if (ctx.method === 'UNLOCK') {
225
+
226
+ async function handleUnlock() {
227
setWebdavHeaders()
228
const x = ctx.get(TOKEN_HEADER).slice(1,-1)
229
const lock = locks.get(path)
230
if (x !== lock?.token)
231
return ctx.status = HTTP_BAD_REQUEST
219
- // with force_webdav_login disabled a client may silently fall back to anonymous; keep lock ownership on the original principal
220
- if (!isSameLockPrincipal(lock, ctx))
232
+ // with force_webdav_login disabled a client may silently fall back to anonymous; keep lock ownership on the original username
233
+ if (!isSameLockUsername(lock, ctx))
234
return ctx.status = HTTP_PRECONDITION_FAILED
235
releaseWebdavLock(path)
236
ctx.set(TOKEN_HEADER, x)
@@ -225,10 +238,11 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
238
urlToNode(path, ctx).then(x => x?.source && dotClean(dirname(x.source)))
239
return ctx.status = HTTP_NO_CONTENT
240
}
228
- if (ctx.method === 'LOCK') {
241
+
242
+ async function handleLock() {
243
setWebdavHeaders()
244
const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await stream2string(ctx.req) : ''
231
- const token = getProvidedLockToken(ctx)
245
+ const token = getProvidedLockToken()
246
let seconds = Number(ctx.get('timeout').split(',').find(x => /^Second-\d+$/i.test(x.trim()))?.trim().split('-', 2)[1])
247
seconds = _.clamp(seconds || LOCK_DEFAULT_SECONDS, 1, LOCK_MAX_SECONDS)
248
@@ -239,10 +253,10 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
253
const lock = locks.get(path)
254
if (token !== lock?.token)
255
return ctx.status = HTTP_PRECONDITION_FAILED
242
- // same-token refresh from another principal would make abandoned locks effectively persistent
243
- if (!isSameLockPrincipal(lock, ctx))
256
+ // same-token refresh from another username would make abandoned locks effectively persistent
257
+ if (!isSameLockUsername(lock, ctx))
258
return ctx.status = HTTP_PRECONDITION_FAILED
245
- // refresh lock – keep the same token on refresh so clients can continue using the lock they already hold
259
+ // refresh lock - keep the same token on refresh so clients can continue using the lock they already hold
260
clearTimeout(lock.timeout)
261
lock.timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000)
262
lock.seconds = seconds
@@ -265,32 +279,12 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
279
return ctx.status = HTTP_LOCKED
280
const newToken = 'urn:uuid:' + randomUUID()
281
const timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000)
268
- locks.set(path, { token: newToken, timeout, seconds, principal: getWebdavPrincipal(ctx) })
282
+ locks.set(path, { token: newToken, timeout, seconds, username: getWebdavUsername(ctx) })
283
ctx.set(TOKEN_HEADER, newToken)
284
ctx.body = renderLockResponse(newToken, seconds)
271
- return
272
-
273
- function getProvidedLockToken(ctx: Koa.Context) {
274
- const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '')
275
- if (direct)
276
- return direct
277
- const ifHeader = ctx.get('If')
278
- return /<([^>]+)>/.exec(ifHeader)?.[1] || ''
279
- }
280
-
281
- function renderLockResponse(token: string, seconds: number) {
282
- return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
283
- <locktype><write/></locktype>
284
- <lockscope><exclusive/></lockscope>
285
- <locktoken><href>${_.escape(token)}</href></locktoken>
286
- <lockroot><href>${_.escape(path)}</href></lockroot>
287
- <depth>0</depth>
288
- <timeout>Second-${seconds}</timeout>
289
- </activelock></lockdiscovery></prop>`
290
- }
291
-
285
}
293
- if (ctx.method === 'PROPFIND') {
286
+
287
+ async function handlePropfind() {
288
setWebdavHeaders()
289
const node = await urlToNode(path, ctx)
290
if (!node) return next()
@@ -304,7 +298,7 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
298
}
299
ctx.type = 'xml'
300
ctx.status = 207
307
- const outPath = enforceFinal('/', path.slice(Math.max(0, (ctx.state.root?.length ?? 0) - 1)), true)
301
+ const outPath = webdavHrefPath(path, node, ctx)
302
const res = ctx.body = new PassThrough({ encoding: 'utf8' })
303
res.write(`<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:">`)
304
await sendEntry(node)
@@ -315,7 +309,6 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
309
}
310
res.write(`</multistatus>`)
311
res.end()
318
- return
312
313
async function sendEntry(node: VfsNode, append=false) {
314
if (nodeIsLink(node)) return
@@ -337,16 +330,34 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
330
`)
331
}
332
}
340
- if (ctx.method === 'PROPPATCH') {
333
+
334
+ async function handleProppatch() {
335
setWebdavHeaders()
342
- return ctx.status = HTTP_METHOD_NOT_ALLOWED
336
+ if (await isLocked(path, ctx)) return
337
+ const node = await urlToNode(path, ctx)
338
+ if (!node) return next()
339
+ if (statusCodeForMissingPerm(node, 'can_see', ctx)) {
340
+ if (ctx.status === HTTP_UNAUTHORIZED)
341
+ setWebdavHeaders(true)
342
+ return
343
+ }
344
+ const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await stream2string(ctx.req) : ''
345
+ const props = try_(() => parseProppatchProps(body)) || []
346
+ if (!props.length)
347
+ return ctx.status = HTTP_BAD_REQUEST
348
+ const statuses = []
349
+ for (const prop of props)
350
+ statuses.push({ prop: prop.name, status: await applyProppatchProp(prop, node, path, ctx) })
351
+ const outPath = webdavHrefPath(path, node, ctx)
352
+ ctx.type = 'xml'
353
+ ctx.status = 207
354
+ ctx.body = renderProppatchResponse(outPath, statuses)
355
}
344
- return next()
356
357
function setWebdavHeaders(authenticate=false) {
358
ctx.set('DAV', '1,2')
359
ctx.set('MS-Author-Via', 'DAV')
349
- ctx.set('Allow', 'PROPFIND,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT')
360
+ ctx.set('Allow', 'PROPFIND,PROPPATCH,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT')
361
if (authenticate)
362
ctx.set('WWW-Authenticate', `Basic realm="HFS WebDAV"`) // keep a dedicated realm for WebDAV so Windows credential cache is isolated from other basic-auth flows
363
}
@@ -374,6 +385,24 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
385
}
386
}
387
388
+ function getProvidedLockToken() {
389
+ const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '')
390
+ if (direct)
391
+ return direct
392
+ const ifHeader = ctx.get('If')
393
+ return /<([^>]+)>/.exec(ifHeader)?.[1] || ''
394
+ }
395
+
396
+ function renderLockResponse(token: string, seconds: number) {
397
+ return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
398
+ <locktype><write/></locktype>
399
+ <lockscope><exclusive/></lockscope>
400
+ <locktoken><href>${_.escape(token)}</href></locktoken>
401
+ <lockroot><href>${_.escape(path)}</href></lockroot>
402
+ <depth>0</depth>
403
+ <timeout>Second-${seconds}</timeout>
404
+ </activelock></lockdiscovery></prop>`
405
+ }
406
}
407
408
function compileWebdavAgentRegex(v: boolean|string) {
@@ -385,6 +414,103 @@ function webdavAgentKey(ctx: Koa.Context, ua: string) {
414
return `${ctx.ip}|${ua}`
415
}
416
417
+function allowWebdavOverwrite(key: string) {
418
+ canOverwrite.add(key)
419
+ setTimeout(() => canOverwrite.delete(key), 10_000) // grace period
420
+}
421
+
422
+function webdavHrefPath(path: string, node: VfsNode, ctx: Koa.Context) {
423
+ const href = path.slice(Math.max(0, (ctx.state.root?.length ?? 0) - 1))
424
+ // WebDAV clients use href shape to infer resource type, so file hrefs must not look like collections
425
+ return nodeIsFolder(node) ? enforceFinal('/', href) : removeFinal('/', href)
426
+}
427
+
428
+interface ProppatchProp {
429
+ name: string
430
+ value: unknown
431
+}
432
+
433
+function parseProppatchProps(body: string) {
434
+ const doc = xmlParser.parse(body)
435
+ const update = getXmlChildren(doc, 'propertyupdate')[0]
436
+ if (!update)
437
+ return []
438
+ const ret: ProppatchProp[] = []
439
+ for (const opName of ['set', 'remove'])
440
+ for (const op of getXmlChildren(update, opName))
441
+ for (const prop of getXmlChildren(op, 'prop'))
442
+ for (const k of Object.keys(prop))
443
+ if (!k.startsWith('@_') && k !== '#text')
444
+ ret.push({ name: localXmlName(k), value: prop[k] })
445
+ return _.uniqBy(ret, 'name')
446
+}
447
+
448
+async function applyProppatchProp(prop: ProppatchProp, node: VfsNode, path: string, ctx: Koa.Context) {
449
+ const k = prop.name.toLowerCase()
450
+ if (PROPPATCH_PROTECTED_LIVE_PROPS.has(k))
451
+ return HTTP_FORBIDDEN
452
+ if (node.source && (PROPPATCH_UTIME_PROPS.has(k) || IS_WINDOWS && k === 'win32fileattributes')) {
453
+ // WebDAV clients patch metadata right after upload; outside that short same-username grace, metadata writes are file modifications
454
+ const missingWritePerm = canOverwrite.has(path + prefix('|', getCurrentUsername(ctx))) ? 0
455
+ : statusCodeForMissingPerm(node, 'can_delete', ctx, false)
456
+ if (missingWritePerm)
457
+ return missingWritePerm
458
+ }
459
+ if (node.source && PROPPATCH_UTIME_PROPS.has(k)) {
460
+ const date = new Date(String(prop.value))
461
+ if (isNaN(Number(date)))
462
+ return HTTP_BAD_REQUEST
463
+ const stats = await nodeStats(node)
464
+ const atime = k === 'win32lastaccesstime' ? date : stats?.atime ?? new Date()
465
+ const mtime = k === 'win32lastmodifiedtime' ? date : stats?.mtime ?? new Date()
466
+ // WebDAV clients often use dead properties for file times; apply the portable subset instead of only pretending success
467
+ await utimes(node.source, atime, mtime)
468
+ }
469
+ if (node.source && IS_WINDOWS && k === 'win32fileattributes') {
470
+ const attributes = parseWindowsFileAttributes(prop.value)
471
+ if (attributes === undefined)
472
+ return HTTP_BAD_REQUEST
473
+ // fswin is already our Windows attribute bridge; this keeps PROPPATCH metadata aligned with the actual filesystem
474
+ const ok = await new Promise<boolean>(resolve =>
475
+ fswin.setAttributes(node.source!, _.mapValues(WINDOWS_FILE_ATTRIBUTE_FLAGS, flag => Boolean(attributes & flag)), ok => resolve(Boolean(ok))) )
476
+ if (!ok)
477
+ return HTTP_SERVER_ERROR
478
+ }
479
+ // PROPPATCH is only persisted when HFS gets real dead-property storage; no-op success keeps Windows and macOS clients from aborting writes
480
+ return HTTP_OK
481
+}
482
+
483
+function parseWindowsFileAttributes(v: unknown) {
484
+ const s = String(v).trim()
485
+ if (!s)
486
+ return
487
+ const n = Number(/^0x/i.test(s) || /^[0-9a-f]{8}$/i.test(s) ? '0x' + s.replace(/^0x/i, '') : s)
488
+ if (!Number.isInteger(n) || n < 0)
489
+ return
490
+ return n
491
+}
492
+
493
+function renderProppatchResponse(path: string, statuses: { prop: string, status: number }[]) {
494
+ const byStatus = _.groupBy(statuses, 'status')
495
+ return `<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:"><response>
496
+ <href>${_.escape(path)}</href>
497
+ ${_.map(byStatus, (items, status) => `<propstat>
498
+ <prop>${items.map(({ prop }) => `<${prop}/>`).join('')}</prop>
499
+ <status>HTTP/1.1 ${status} ${_.escape(HTTP_MESSAGES[Number(status)] || STATUS_CODES[Number(status)] || '')}</status>
500
+ </propstat>`).join('')}
501
+ </response></multistatus>`
502
+}
503
+
504
+function getXmlChildren(obj: unknown, name: string) {
505
+ if (!obj || typeof obj !== 'object')
506
+ return []
507
+ return Object.entries(obj).flatMap(([k, v]) => localXmlName(k) === name ? wantArray(v) : [])
508
+}
509
+
510
+function localXmlName(name: string) {
511
+ return name.split(':').at(-1) || name
512
+}
513
+
514
// Finder will upload special attributes as files with name ._* that can be merged using system utility "dot_clean"
515
const cleaners: Dict<Timeout> = {}
516
function dotClean(path: string) {
@@ -403,4 +529,4 @@ declare module "koa" {
529
interface DefaultState {
530
webdavDetected?: boolean
531
}
406
-}
\ No newline at end of file
532
+}
tests/test.ts
+100
@@ -53,6 +53,16 @@ const WEBDAV_SHARED_LOCK_BODY = `<?xml version="1.0" encoding="utf-8"?>
53
<lockscope><shared/></lockscope>
54
<locktype><write/></locktype>
55
</lockinfo>`
56
+const WEBDAV_PROPPATCH_BODY = `<?xml version="1.0" encoding="utf-8"?>
57
+<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:schemas-microsoft-com:">
58
+ <D:set>
59
+ <D:prop>
60
+ <Z:Win32LastModifiedTime>Mon, 04 May 2026 10:00:00 GMT</Z:Win32LastModifiedTime>
61
+ <Z:Win32FileAttributes>00000020</Z:Win32FileAttributes>
62
+ <D:getlastmodified>Mon, 04 May 2026 10:00:00 GMT</D:getlastmodified>
63
+ </D:prop>
64
+ </D:set>
65
+</D:propertyupdate>`
66
let defaultBaseUrl = BASE_URL
67
68
const execP = (cmd: string) => promisify(exec)(cmd).then(x => x.stdout)
@@ -518,6 +528,96 @@ describe('webdav', () => {
528
}
529
}
530
})
531
+ test('webdav.proppatch accepts dead properties as no-op', async () => {
532
+ const name = `wd-proppatch-${randomId(6)}.txt`
533
+ const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}`
534
+ let destPath = ''
535
+ try {
536
+ destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')()
537
+ await req(uri, data => data.includes(`<href>${uri}</href>`) && !data.includes(`<href>${uri}/</href>`), {
538
+ method: 'PROPFIND',
539
+ auth,
540
+ jar,
541
+ headers: { depth: '0', 'user-agent': WEBDAV_UA },
542
+ })()
543
+ await req(uri, (data, res) => {
544
+ if (res.statusCode !== 207)
545
+ throw `expected 207, got ${res.statusCode}`
546
+ if (XMLValidator.validate(data) !== true)
547
+ throw "invalid XML"
548
+ if (!/<Win32LastModifiedTime\/>[\s\S]*HTTP\/1\.1 200 OK/.test(data))
549
+ throw "missing no-op success for Windows property"
550
+ if (!/<getlastmodified\/>[\s\S]*HTTP\/1\.1 403 Forbidden/.test(data))
551
+ throw "missing forbidden status for protected live property"
552
+ }, {
553
+ method: 'PROPPATCH',
554
+ auth,
555
+ jar,
556
+ headers: {
557
+ 'content-type': 'text/xml',
558
+ 'content-length': Buffer.byteLength(WEBDAV_PROPPATCH_BODY),
559
+ 'user-agent': WEBDAV_UA,
560
+ },
561
+ body: WEBDAV_PROPPATCH_BODY,
562
+ })()
563
+ if (Math.abs(statSync(destPath).mtimeMs - Date.parse('Mon, 04 May 2026 10:00:00 GMT')) > 1000)
564
+ throw "mtime was not updated"
565
+ }
566
+ finally {
567
+ await rmAny(destPath)
568
+ }
569
+ })
570
+ test('webdav.proppatch requires upload permission for timestamp changes', req('/f1/f2/alfa.txt', data =>
571
+ /<Win32LastModifiedTime\/>[\s\S]*HTTP\/1\.1 403 Forbidden/.test(data), {
572
+ method: 'PROPPATCH',
573
+ auth,
574
+ jar,
575
+ headers: {
576
+ 'content-type': 'text/xml',
577
+ 'content-length': Buffer.byteLength(WEBDAV_PROPPATCH_BODY),
578
+ 'user-agent': WEBDAV_UA,
579
+ },
580
+ body: WEBDAV_PROPPATCH_BODY,
581
+ }))
582
+ test('webdav.proppatch metadata grace is bound to recent upload', async () => {
583
+ const staleName = `wd-proppatch-stale-${randomId(6)}.txt`
584
+ const freshName = `wd-proppatch-fresh-${randomId(6)}.txt`
585
+ const staleUri = `${CANT_OVERWRITE_URI}${staleName}`
586
+ const freshUri = `${CANT_OVERWRITE_URI}${freshName}`
587
+ const dir = await ensureCantOverwriteDir()
588
+ const stalePath = resolve(dir, staleName)
589
+ let freshPath = ''
590
+ try {
591
+ await writeFile(stalePath, 'stale')
592
+ await req(staleUri, data => /<Win32LastModifiedTime\/>[\s\S]*HTTP\/1\.1 403 Forbidden/.test(data), {
593
+ method: 'PROPPATCH',
594
+ auth,
595
+ jar,
596
+ headers: {
597
+ 'content-type': 'text/xml',
598
+ 'content-length': Buffer.byteLength(WEBDAV_PROPPATCH_BODY),
599
+ 'user-agent': WEBDAV_UA,
600
+ },
601
+ body: WEBDAV_PROPPATCH_BODY,
602
+ })()
603
+ freshPath = await webdavUpload(freshUri, x => x?.uri === freshUri, 'fresh')()
604
+ await req(freshUri, data => /<Win32LastModifiedTime\/>[\s\S]*HTTP\/1\.1 200 OK/.test(data), {
605
+ method: 'PROPPATCH',
606
+ auth,
607
+ jar,
608
+ headers: {
609
+ 'content-type': 'text/xml',
610
+ 'content-length': Buffer.byteLength(WEBDAV_PROPPATCH_BODY),
611
+ 'user-agent': WEBDAV_UA,
612
+ },
613
+ body: WEBDAV_PROPPATCH_BODY,
614
+ })()
615
+ }
616
+ finally {
617
+ await rmAny(stalePath)
618
+ await rmAny(freshPath)
619
+ }
620
+ })
621
test('webdav.escaping', req('/f1/hidden', data => XMLValidator.validate(data) === true, { method: 'PROPFIND', auth, jar: {}, headers: { depth: '1' } }))
622
623
function webdavUpload(uri: string, tester: Tester, body: string, userAgent=WEBDAV_UA) {