hfs 3.1.2 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/webdav.js CHANGED
@@ -10,11 +10,13 @@ const vfs_1 = require("./vfs");
10
10
  const cross_1 = require("./cross");
11
11
  const stream_1 = require("stream");
12
12
  const promises_1 = require("fs/promises");
13
+ const http_1 = require("http");
13
14
  const misc_1 = require("./misc");
14
15
  const path_1 = require("path");
15
16
  const frontEndApis_1 = require("./frontEndApis");
16
17
  const node_crypto_1 = require("node:crypto");
17
18
  const const_1 = require("./const");
19
+ const fswin_1 = __importDefault(require("fswin"));
18
20
  const child_process_1 = require("child_process");
19
21
  const auth_1 = require("./auth");
20
22
  const config_1 = require("./config");
@@ -26,12 +28,26 @@ const webdavInitialAuth = (0, config_1.defineConfig)(cross_1.CFG.webdav_initial_
26
28
  const webdavPrompted = (0, expiringCache_1.expiringCache)(cross_1.DAY);
27
29
  const webdavDetectedAgents = (0, expiringCache_1.expiringCache)(cross_1.DAY);
28
30
  const TOKEN_HEADER = 'lock-token';
29
- const WEBDAV_METHODS = new Set(['PROPFIND', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK']);
31
+ const WEBDAV_METHODS = new Set(['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK']);
30
32
  const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length'];
31
- const KNOWN_UA = /webdav|miniredir|davclnt/i;
33
+ const KNOWN_UA = /webdav|miniredir|davclnt|microsoft office|ms-office/i;
32
34
  const LOCK_DEFAULT_SECONDS = 3600;
33
35
  const LOCK_MAX_SECONDS = cross_1.DAY / 1000;
34
36
  const xmlParser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true });
37
+ const PROPPATCH_PROTECTED_LIVE_PROPS = new Set([
38
+ 'creationdate', 'displayname', 'getcontentlanguage', 'getcontentlength', 'getcontenttype',
39
+ 'getetag', 'getlastmodified', 'lockdiscovery', 'resourcetype', 'supportedlock',
40
+ ]);
41
+ const PROPPATCH_UTIME_PROPS = new Set(['win32lastmodifiedtime', 'win32lastaccesstime']);
42
+ const WINDOWS_FILE_ATTRIBUTE_FLAGS = {
43
+ IS_READ_ONLY: 0x1,
44
+ IS_HIDDEN: 0x2,
45
+ IS_SYSTEM: 0x4,
46
+ IS_ARCHIVED: 0x20,
47
+ IS_TEMPORARY: 0x100,
48
+ IS_OFFLINE: 0x1000,
49
+ IS_NOT_CONTENT_INDEXED: 0x2000,
50
+ };
35
51
  const canOverwrite = new Set();
36
52
  const locks = new Map();
37
53
  function releaseWebdavLock(path) {
@@ -53,7 +69,7 @@ async function isLocked(path, ctx) {
53
69
  }
54
70
  const ifHeader = ctx.get('If');
55
71
  const tokenHeader = ctx.get(TOKEN_HEADER);
56
- if (isSameLockPrincipal(lock, ctx) && (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token)))
72
+ if (isSameLockUsername(lock, ctx) && (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token)))
57
73
  return false;
58
74
  ctx.status = cross_1.HTTP_LOCKED;
59
75
  return true;
@@ -63,11 +79,11 @@ function hasToken(header, token) {
63
79
  return false;
64
80
  return header.includes(`<${token}>`) || header.split(/[,;\s]+/).includes(token);
65
81
  }
66
- function getWebdavPrincipal(ctx) {
82
+ function getWebdavUsername(ctx) {
67
83
  return (0, auth_1.getCurrentUsername)(ctx) || '';
68
84
  }
69
- function isSameLockPrincipal(lock, ctx) {
70
- return lock.principal === getWebdavPrincipal(ctx);
85
+ function isSameLockUsername(lock, ctx) {
86
+ return lock.username === getWebdavUsername(ctx);
71
87
  }
72
88
  const webdav = async (ctx, next) => {
73
89
  let { path } = ctx;
@@ -78,31 +94,57 @@ const webdav = async (ctx, next) => {
78
94
  ctx.state.webdavDetected = true;
79
95
  return ctx.status = cross_1.HTTP_FORBIDDEN;
80
96
  }
81
- const isWebdavAuthRequest = WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => ctx.get(h));
97
+ // office starts document access with OPTIONS, then LOCK/GET; challenging OPTIONS keeps the whole exchange in the same WebDAV auth realm
98
+ const isCorsPreflight = ctx.method === 'OPTIONS' && ctx.get('Access-Control-Request-Method');
99
+ const isKnownWebdavAgent = KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua));
100
+ const isWebdavAuthRequest = !isCorsPreflight && (ctx.method === 'OPTIONS' || WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => ctx.get(h))
101
+ || ctx.method === 'GET' && isKnownWebdavAgent);
82
102
  if (isWebdavAuthRequest)
83
103
  ctx.state.webdavDetected = true;
84
104
  if (isWebdavAuthRequest && ua && (0, auth_1.getCurrentUsername)(ctx))
85
105
  webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true);
86
- if (ctx.method === 'OPTIONS') {
87
- if (ctx.get('Access-Control-Request-Method')) // it's a preflight cors request, not webdav
88
- return next();
106
+ if (isCorsPreflight)
107
+ return next();
108
+ if (isWebdavAuthRequest && shouldChallengeWebdav())
109
+ return;
110
+ if (ctx.method === 'OPTIONS')
111
+ return handleOptions();
112
+ if (ctx.method === 'GET' && isWebdavAuthRequest)
113
+ return handleGet();
114
+ switch (ctx.method) {
115
+ case 'PUT': return handlePut();
116
+ case 'MKCOL': return handleMkcol();
117
+ case 'MOVE': return handleMove();
118
+ case 'DELETE': return handleDelete();
119
+ case 'UNLOCK': return handleUnlock();
120
+ case 'LOCK': return handleLock();
121
+ case 'PROPFIND': return handlePropfind();
122
+ case 'PROPPATCH': return handleProppatch();
123
+ }
124
+ return next();
125
+ async function handleOptions() {
89
126
  setWebdavHeaders();
90
127
  ctx.body = '';
91
- return;
92
128
  }
93
- if (isWebdavAuthRequest && shouldChallengeWebdav())
94
- return;
95
- if (ctx.method === 'PUT') {
129
+ async function handleGet() {
130
+ const node = await (0, vfs_1.urlToNode)(path, ctx);
131
+ if (!node || (0, vfs_1.nodeIsFolder)(node))
132
+ return next();
133
+ // webdav file reads must not fall through to the browser frontend when auth rejects them
134
+ if ((0, vfs_1.statusCodeForMissingPerm)(node, 'can_read', ctx)) {
135
+ if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
136
+ setWebdavHeaders(true);
137
+ return;
138
+ }
139
+ return next();
140
+ }
141
+ async function handlePut() {
96
142
  if (await isLocked(path, ctx))
97
143
  return;
98
144
  const overwriteGraceKey = path + (0, cross_1.prefix)('|', (0, auth_1.getCurrentUsername)(ctx)); // bind temporary overwrite grace to the authenticated user so accounts cannot reuse each other's grace window
99
145
  // 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.
100
146
  const x = ctx.get('x-expected-entity-length'); // field used by Finder's webdav on actual upload, after
101
- if (!x && !ctx.length) {
102
- canOverwrite.add(overwriteGraceKey);
103
- setTimeout(() => canOverwrite.delete(overwriteGraceKey), 10_000); // grace period
104
- }
105
- else if (canOverwrite.has(overwriteGraceKey)) {
147
+ if (isKnownWebdavAgent && canOverwrite.has(overwriteGraceKey)) {
106
148
  canOverwrite.delete(overwriteGraceKey);
107
149
  const node = await (0, vfs_1.urlToNode)(path, ctx);
108
150
  if (node?.source)
@@ -110,11 +152,13 @@ const webdav = async (ctx, next) => {
110
152
  }
111
153
  if (x && ctx.length === undefined) // missing length can make PUT fail
112
154
  ctx.req.headers['content-length'] = x;
113
- if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua)))
155
+ if (isKnownWebdavAgent)
114
156
  ctx.query.existing ??= 'overwrite'; // with webdav this is our default
115
- return next();
157
+ await next();
158
+ if (isKnownWebdavAgent && ctx.body?.uri === path) // the upload middleware reports the final uri that can be different from the initial request
159
+ allowWebdavOverwrite(overwriteGraceKey);
116
160
  }
117
- if (ctx.method === 'MKCOL') {
161
+ async function handleMkcol() {
118
162
  setWebdavHeaders();
119
163
  if (await isLocked(path, ctx))
120
164
  return;
@@ -142,7 +186,7 @@ const webdav = async (ctx, next) => {
142
186
  return ctx.status = cross_1.HTTP_SERVER_ERROR;
143
187
  }
144
188
  }
145
- if (ctx.method === 'MOVE') {
189
+ async function handleMove() {
146
190
  setWebdavHeaders();
147
191
  if (await isLocked(path, ctx))
148
192
  return;
@@ -177,23 +221,22 @@ const webdav = async (ctx, next) => {
177
221
  releaseWebdavLock(path); // successful MOVE leaves the old path invalid, therefore its lock must be dropped
178
222
  return ctx.status = !err ? cross_1.HTTP_CREATED : typeof err === 'number' ? err : cross_1.HTTP_SERVER_ERROR;
179
223
  }
180
- if (ctx.method === 'DELETE') {
224
+ async function handleDelete() {
181
225
  setWebdavHeaders();
182
226
  if (await isLocked(path, ctx))
183
227
  return;
184
228
  await next();
185
229
  if (ctx.status === cross_1.HTTP_OK)
186
230
  releaseWebdavLock(path); // webdav clients may forget UNLOCK; successful delete must clear any lock
187
- return;
188
231
  }
189
- if (ctx.method === 'UNLOCK') {
232
+ async function handleUnlock() {
190
233
  setWebdavHeaders();
191
234
  const x = ctx.get(TOKEN_HEADER).slice(1, -1);
192
235
  const lock = locks.get(path);
193
236
  if (x !== lock?.token)
194
237
  return ctx.status = cross_1.HTTP_BAD_REQUEST;
195
- // with force_webdav_login disabled a client may silently fall back to anonymous; keep lock ownership on the original principal
196
- if (!isSameLockPrincipal(lock, ctx))
238
+ // with force_webdav_login disabled a client may silently fall back to anonymous; keep lock ownership on the original username
239
+ if (!isSameLockUsername(lock, ctx))
197
240
  return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
198
241
  releaseWebdavLock(path);
199
242
  ctx.set(TOKEN_HEADER, x);
@@ -201,10 +244,10 @@ const webdav = async (ctx, next) => {
201
244
  (0, vfs_1.urlToNode)(path, ctx).then(x => x?.source && dotClean((0, path_1.dirname)(x.source)));
202
245
  return ctx.status = cross_1.HTTP_NO_CONTENT;
203
246
  }
204
- if (ctx.method === 'LOCK') {
247
+ async function handleLock() {
205
248
  setWebdavHeaders();
206
249
  const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await (0, consumers_1.text)(ctx.req) : '';
207
- const token = getProvidedLockToken(ctx);
250
+ const token = getProvidedLockToken();
208
251
  let seconds = Number(ctx.get('timeout').split(',').find(x => /^Second-\d+$/i.test(x.trim()))?.trim().split('-', 2)[1]);
209
252
  seconds = lodash_1.default.clamp(seconds || LOCK_DEFAULT_SECONDS, 1, LOCK_MAX_SECONDS);
210
253
  if (!body) {
@@ -214,10 +257,10 @@ const webdav = async (ctx, next) => {
214
257
  const lock = locks.get(path);
215
258
  if (token !== lock?.token)
216
259
  return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
217
- // same-token refresh from another principal would make abandoned locks effectively persistent
218
- if (!isSameLockPrincipal(lock, ctx))
260
+ // same-token refresh from another username would make abandoned locks effectively persistent
261
+ if (!isSameLockUsername(lock, ctx))
219
262
  return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
220
- // refresh lock keep the same token on refresh so clients can continue using the lock they already hold
263
+ // refresh lock - keep the same token on refresh so clients can continue using the lock they already hold
221
264
  clearTimeout(lock.timeout);
222
265
  lock.timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000);
223
266
  lock.seconds = seconds;
@@ -239,29 +282,11 @@ const webdav = async (ctx, next) => {
239
282
  return ctx.status = cross_1.HTTP_LOCKED;
240
283
  const newToken = 'urn:uuid:' + (0, node_crypto_1.randomUUID)();
241
284
  const timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000);
242
- locks.set(path, { token: newToken, timeout, seconds, principal: getWebdavPrincipal(ctx) });
285
+ locks.set(path, { token: newToken, timeout, seconds, username: getWebdavUsername(ctx) });
243
286
  ctx.set(TOKEN_HEADER, newToken);
244
287
  ctx.body = renderLockResponse(newToken, seconds);
245
- return;
246
- function getProvidedLockToken(ctx) {
247
- const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '');
248
- if (direct)
249
- return direct;
250
- const ifHeader = ctx.get('If');
251
- return /<([^>]+)>/.exec(ifHeader)?.[1] || '';
252
- }
253
- function renderLockResponse(token, seconds) {
254
- return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
255
- <locktype><write/></locktype>
256
- <lockscope><exclusive/></lockscope>
257
- <locktoken><href>${lodash_1.default.escape(token)}</href></locktoken>
258
- <lockroot><href>${lodash_1.default.escape(path)}</href></lockroot>
259
- <depth>0</depth>
260
- <timeout>Second-${seconds}</timeout>
261
- </activelock></lockdiscovery></prop>`;
262
- }
263
288
  }
264
- if (ctx.method === 'PROPFIND') {
289
+ async function handlePropfind() {
265
290
  setWebdavHeaders();
266
291
  const node = await (0, vfs_1.urlToNode)(path, ctx);
267
292
  if (!node)
@@ -276,7 +301,7 @@ const webdav = async (ctx, next) => {
276
301
  }
277
302
  ctx.type = 'xml';
278
303
  ctx.status = 207;
279
- const outPath = (0, cross_1.enforceFinal)('/', path.slice(Math.max(0, (ctx.state.root?.length ?? 0) - 1)), true);
304
+ const outPath = webdavHrefPath(path, node, ctx);
280
305
  const res = ctx.body = new stream_1.PassThrough({ encoding: 'utf8' });
281
306
  res.write(`<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:">`);
282
307
  await sendEntry(node);
@@ -287,7 +312,6 @@ const webdav = async (ctx, next) => {
287
312
  }
288
313
  res.write(`</multistatus>`);
289
314
  res.end();
290
- return;
291
315
  async function sendEntry(node, append = false) {
292
316
  if ((0, vfs_1.nodeIsLink)(node))
293
317
  return;
@@ -309,17 +333,36 @@ const webdav = async (ctx, next) => {
309
333
  `);
310
334
  }
311
335
  }
312
- if (ctx.method === 'PROPPATCH') {
336
+ async function handleProppatch() {
313
337
  setWebdavHeaders();
314
- return ctx.status = cross_1.HTTP_METHOD_NOT_ALLOWED;
338
+ if (await isLocked(path, ctx))
339
+ return;
340
+ const node = await (0, vfs_1.urlToNode)(path, ctx);
341
+ if (!node)
342
+ return next();
343
+ if ((0, vfs_1.statusCodeForMissingPerm)(node, 'can_see', ctx)) {
344
+ if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
345
+ setWebdavHeaders(true);
346
+ return;
347
+ }
348
+ const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await (0, consumers_1.text)(ctx.req) : '';
349
+ const props = (0, cross_1.try_)(() => parseProppatchProps(body)) || [];
350
+ if (!props.length)
351
+ return ctx.status = cross_1.HTTP_BAD_REQUEST;
352
+ const statuses = [];
353
+ for (const prop of props)
354
+ statuses.push({ prop: prop.name, status: await applyProppatchProp(prop, node, path, ctx) });
355
+ const outPath = webdavHrefPath(path, node, ctx);
356
+ ctx.type = 'xml';
357
+ ctx.status = 207;
358
+ ctx.body = renderProppatchResponse(outPath, statuses);
315
359
  }
316
- return next();
317
360
  function setWebdavHeaders(authenticate = false) {
318
361
  ctx.set('DAV', '1,2');
319
362
  ctx.set('MS-Author-Via', 'DAV');
320
- ctx.set('Allow', 'PROPFIND,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT');
363
+ ctx.set('Allow', 'PROPFIND,PROPPATCH,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT');
321
364
  if (authenticate)
322
- 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
365
+ ctx.set('WWW-Authenticate', cross_1.BASIC_AUTHENTICATE_HEADER);
323
366
  }
324
367
  function shouldChallengeWebdav() {
325
368
  if ((0, auth_1.getCurrentUsername)(ctx))
@@ -342,6 +385,23 @@ const webdav = async (ctx, next) => {
342
385
  return true;
343
386
  }
344
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
+ function renderLockResponse(token, seconds) {
396
+ return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
397
+ <locktype><write/></locktype>
398
+ <lockscope><exclusive/></lockscope>
399
+ <locktoken><href>${lodash_1.default.escape(token)}</href></locktoken>
400
+ <lockroot><href>${lodash_1.default.escape(path)}</href></lockroot>
401
+ <depth>0</depth>
402
+ <timeout>Second-${seconds}</timeout>
403
+ </activelock></lockdiscovery></prop>`;
404
+ }
345
405
  };
346
406
  exports.webdav = webdav;
347
407
  function compileWebdavAgentRegex(v) {
@@ -351,6 +411,89 @@ function webdavAgentKey(ctx, ua) {
351
411
  // tying detection to source IP avoids promoting one spoofed UA to global WebDAV behavior
352
412
  return `${ctx.ip}|${ua}`;
353
413
  }
414
+ function allowWebdavOverwrite(key) {
415
+ canOverwrite.add(key);
416
+ setTimeout(() => canOverwrite.delete(key), 10_000); // grace period
417
+ }
418
+ function webdavHrefPath(path, node, ctx) {
419
+ const href = path.slice(Math.max(0, (ctx.state.root?.length ?? 0) - 1));
420
+ // WebDAV clients use href shape to infer resource type, so file hrefs must not look like collections
421
+ return (0, vfs_1.nodeIsFolder)(node) ? (0, cross_1.enforceFinal)('/', href) : (0, cross_1.removeFinal)('/', href);
422
+ }
423
+ function parseProppatchProps(body) {
424
+ const doc = xmlParser.parse(body);
425
+ const update = getXmlChildren(doc, 'propertyupdate')[0];
426
+ if (!update)
427
+ return [];
428
+ const ret = [];
429
+ for (const opName of ['set', 'remove'])
430
+ for (const op of getXmlChildren(update, opName))
431
+ for (const prop of getXmlChildren(op, 'prop'))
432
+ for (const k of Object.keys(prop))
433
+ if (!k.startsWith('@_') && k !== '#text')
434
+ ret.push({ name: localXmlName(k), value: prop[k] });
435
+ return lodash_1.default.uniqBy(ret, 'name');
436
+ }
437
+ async function applyProppatchProp(prop, node, path, ctx) {
438
+ const k = prop.name.toLowerCase();
439
+ if (PROPPATCH_PROTECTED_LIVE_PROPS.has(k))
440
+ return cross_1.HTTP_FORBIDDEN;
441
+ if (node.source && (PROPPATCH_UTIME_PROPS.has(k) || const_1.IS_WINDOWS && k === 'win32fileattributes')) {
442
+ // WebDAV clients patch metadata right after upload; outside that short same-username grace, metadata writes are file modifications
443
+ const missingWritePerm = canOverwrite.has(path + (0, cross_1.prefix)('|', (0, auth_1.getCurrentUsername)(ctx))) ? 0
444
+ : (0, vfs_1.statusCodeForMissingPerm)(node, 'can_delete', ctx, false);
445
+ if (missingWritePerm)
446
+ return missingWritePerm;
447
+ }
448
+ if (node.source && PROPPATCH_UTIME_PROPS.has(k)) {
449
+ const date = new Date(String(prop.value));
450
+ if (isNaN(Number(date)))
451
+ return cross_1.HTTP_BAD_REQUEST;
452
+ const stats = await (0, vfs_1.nodeStats)(node);
453
+ const atime = k === 'win32lastaccesstime' ? date : stats?.atime ?? new Date();
454
+ const mtime = k === 'win32lastmodifiedtime' ? date : stats?.mtime ?? new Date();
455
+ // WebDAV clients often use dead properties for file times; apply the portable subset instead of only pretending success
456
+ await (0, promises_1.utimes)(node.source, atime, mtime);
457
+ }
458
+ if (node.source && const_1.IS_WINDOWS && k === 'win32fileattributes') {
459
+ const attributes = parseWindowsFileAttributes(prop.value);
460
+ if (attributes === undefined)
461
+ return cross_1.HTTP_BAD_REQUEST;
462
+ // fswin is already our Windows attribute bridge; this keeps PROPPATCH metadata aligned with the actual filesystem
463
+ const ok = await new Promise(resolve => fswin_1.default.setAttributes(node.source, lodash_1.default.mapValues(WINDOWS_FILE_ATTRIBUTE_FLAGS, flag => Boolean(attributes & flag)), ok => resolve(Boolean(ok))));
464
+ if (!ok)
465
+ return cross_1.HTTP_SERVER_ERROR;
466
+ }
467
+ // PROPPATCH is only persisted when HFS gets real dead-property storage; no-op success keeps Windows and macOS clients from aborting writes
468
+ return cross_1.HTTP_OK;
469
+ }
470
+ function parseWindowsFileAttributes(v) {
471
+ const s = String(v).trim();
472
+ if (!s)
473
+ return;
474
+ const n = Number(/^0x/i.test(s) || /^[0-9a-f]{8}$/i.test(s) ? '0x' + s.replace(/^0x/i, '') : s);
475
+ if (!Number.isInteger(n) || n < 0)
476
+ return;
477
+ return n;
478
+ }
479
+ function renderProppatchResponse(path, statuses) {
480
+ const byStatus = lodash_1.default.groupBy(statuses, 'status');
481
+ return `<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:"><response>
482
+ <href>${lodash_1.default.escape(path)}</href>
483
+ ${lodash_1.default.map(byStatus, (items, status) => `<propstat>
484
+ <prop>${items.map(({ prop }) => `<${prop}/>`).join('')}</prop>
485
+ <status>HTTP/1.1 ${status} ${lodash_1.default.escape(cross_1.HTTP_MESSAGES[Number(status)] || http_1.STATUS_CODES[Number(status)] || '')}</status>
486
+ </propstat>`).join('')}
487
+ </response></multistatus>`;
488
+ }
489
+ function getXmlChildren(obj, name) {
490
+ if (!obj || typeof obj !== 'object')
491
+ return [];
492
+ return Object.entries(obj).flatMap(([k, v]) => localXmlName(k) === name ? (0, cross_1.wantArray)(v) : []);
493
+ }
494
+ function localXmlName(name) {
495
+ return name.split(':').at(-1) || name;
496
+ }
354
497
  // Finder will upload special attributes as files with name ._* that can be merged using system utility "dot_clean"
355
498
  const cleaners = {};
356
499
  function dotClean(path) {