@samitouri / QOSami-HFS / commits / 5ac041da

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) {