"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.webdav = void 0;
exports.releaseWebdavLock = releaseWebdavLock;
const consumers_1 = require("node:stream/consumers");
const vfs_1 = require("./vfs");
const cross_1 = require("./cross");
const stream_1 = require("stream");
const promises_1 = require("fs/promises");
const http_1 = require("http");
const misc_1 = require("./misc");
const path_1 = require("path");
const frontEndApis_1 = require("./frontEndApis");
const node_crypto_1 = require("node:crypto");
const const_1 = require("./const");
const fswin_1 = __importDefault(require("fswin"));
const child_process_1 = require("child_process");
const auth_1 = require("./auth");
const config_1 = require("./config");
const expiringCache_1 = require("./expiringCache");
const fast_xml_parser_1 = require("fast-xml-parser");
const lodash_1 = __importDefault(require("lodash"));
const fileAttr_1 = require("./fileAttr");
const forceWebdavLogin = (0, config_1.defineConfig)(cross_1.CFG.force_webdav_login, true, compileWebdavAgentRegex);
const webdavInitialAuth = (0, config_1.defineConfig)(cross_1.CFG.webdav_initial_auth, 'WebDAVFS', compileWebdavAgentRegex);
const webdavPrompted = (0, expiringCache_1.expiringCache)(cross_1.DAY);
const webdavDetectedAgents = (0, expiringCache_1.expiringCache)(cross_1.DAY);
const TOKEN_HEADER = 'lock-token';
const WEBDAV_METHODS = new Set(['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK']);
const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length'];
const KNOWN_UA = /webdav|miniredir|davclnt|microsoft office|ms-office/i;
const LOCK_DEFAULT_SECONDS = 3600;
const LOCK_MAX_SECONDS = cross_1.DAY / 1000;
const xmlParser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true });
const PROPPATCH_PROTECTED_LIVE_PROPS = new Set([
    'creationdate', 'displayname', 'getcontentlanguage', 'getcontentlength', 'getcontenttype',
    'getetag', 'getlastmodified', 'lockdiscovery', 'resourcetype', 'supportedlock',
]);
const PROPPATCH_UTIME_PROPS = new Set(['win32lastmodifiedtime', 'win32lastaccesstime']);
const WINDOWS_FILE_ATTRIBUTE_FLAGS = {
    IS_READ_ONLY: 0x1,
    IS_HIDDEN: 0x2,
    IS_SYSTEM: 0x4,
    IS_ARCHIVED: 0x20,
    IS_TEMPORARY: 0x100,
    IS_OFFLINE: 0x1000,
    IS_NOT_CONTENT_INDEXED: 0x2000,
};
const canOverwrite = new Set();
const locks = new Map();
function releaseWebdavLock(path) {
    const lock = locks.get(path);
    if (!lock)
        return false;
    clearTimeout(lock.timeout);
    locks.delete(path);
    return true;
}
async function isLocked(path, ctx) {
    const lock = locks.get(path);
    if (!lock)
        return false;
    // if the resource is gone, keeping the lock only creates fake 423 responses
    if (!await (0, vfs_1.urlToNode)(path, ctx)) {
        releaseWebdavLock(path);
        return false;
    }
    const ifHeader = ctx.get('If');
    const tokenHeader = ctx.get(TOKEN_HEADER);
    if (isSameLockUsername(lock, ctx) && (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token)))
        return false;
    ctx.status = cross_1.HTTP_LOCKED;
    return true;
}
function hasToken(header, token) {
    if (!header)
        return false;
    return header.includes(`<${token}>`) || header.split(/[,;\s]+/).includes(token);
}
function getWebdavUsername(ctx) {
    return (0, auth_1.getCurrentUsername)(ctx) || '';
}
function isSameLockUsername(lock, ctx) {
    return lock.username === getWebdavUsername(ctx);
}
const webdav = async (ctx, next) => {
    let { path } = ctx;
    path = path.replace(/^\/+/, '/'); // double-slash is causing empty listing in filezilla-pro
    const ua = ctx.get('user-agent');
    if (path.includes('/._') && ua?.startsWith('WebDAVFS')) { // too much spam from Finder for these files that can contain metas
        ctx.state.dontLog = true;
        ctx.state.webdavDetected = true;
        return ctx.status = cross_1.HTTP_FORBIDDEN;
    }
    // office starts document access with OPTIONS, then LOCK/GET; challenging OPTIONS keeps the whole exchange in the same WebDAV auth realm
    const isCorsPreflight = ctx.method === 'OPTIONS' && ctx.get('Access-Control-Request-Method');
    const isKnownWebdavAgent = KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua));
    const isWebdavAuthRequest = !isCorsPreflight && (ctx.method === 'OPTIONS' || WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => ctx.get(h))
        || ctx.method === 'GET' && isKnownWebdavAgent);
    if (isWebdavAuthRequest)
        ctx.state.webdavDetected = true;
    if (isWebdavAuthRequest && ua && (0, auth_1.getCurrentUsername)(ctx))
        webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true);
    if (isCorsPreflight)
        return next();
    if (isWebdavAuthRequest && shouldChallengeWebdav())
        return;
    if (ctx.method === 'OPTIONS')
        return handleOptions();
    if (ctx.method === 'GET' && isWebdavAuthRequest)
        return handleGet();
    switch (ctx.method) {
        case 'PUT': return handlePut();
        case 'MKCOL': return handleMkcol();
        case 'MOVE': return handleMove();
        case 'DELETE': return handleDelete();
        case 'UNLOCK': return handleUnlock();
        case 'LOCK': return handleLock();
        case 'PROPFIND': return handlePropfind();
        case 'PROPPATCH': return handleProppatch();
    }
    return next();
    async function handleOptions() {
        setWebdavHeaders();
        ctx.body = '';
    }
    async function handleGet() {
        const node = await (0, vfs_1.urlToNode)(path, ctx);
        if (!node || (0, vfs_1.nodeIsFolder)(node))
            return next();
        // webdav file reads must not fall through to the browser frontend when auth rejects them
        if ((0, vfs_1.statusCodeForMissingPerm)(node, 'can_read', ctx)) {
            if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
                setWebdavHeaders(true);
            return;
        }
        return next();
    }
    async function handlePut() {
        if (await isLocked(path, ctx))
            return;
        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
        // 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.
        const x = ctx.get('x-expected-entity-length'); // field used by Finder's webdav on actual upload, after
        if (isKnownWebdavAgent && canOverwrite.has(overwriteGraceKey)) {
            canOverwrite.delete(overwriteGraceKey);
            const node = await (0, vfs_1.urlToNode)(path, ctx);
            if (node?.source)
                await (0, promises_1.rm)(node.source)
                    .then(() => (0, fileAttr_1.deleteStoredFileAttrs)(node.source))
                    .catch(() => { });
        }
        if (x && ctx.length === undefined) // missing length can make PUT fail
            ctx.req.headers['content-length'] = x;
        if (isKnownWebdavAgent)
            ctx.query.existing ??= 'overwrite'; // with webdav this is our default
        await next();
        if (isKnownWebdavAgent && ctx.body?.uri === path) // the upload middleware reports the final uri that can be different from the initial request
            allowWebdavOverwrite(overwriteGraceKey);
    }
    async function handleMkcol() {
        setWebdavHeaders();
        if (await isLocked(path, ctx))
            return;
        const node = await (0, vfs_1.urlToNode)(path, ctx);
        if (node) {
            ctx.status = cross_1.HTTP_METHOD_NOT_ALLOWED;
            return;
        }
        const parentNode = await (0, vfs_1.urlToNode)((0, path_1.dirname)(path), ctx);
        if (!parentNode) // this is a bit incoherent with the way we handle PUT, which doesn't stop in this case, but it's by RFC 4918 section 9.3
            return ctx.status = cross_1.HTTP_CONFLICT;
        const name = (0, cross_1.safeDecodeURIComponent)((0, path_1.basename)(path), '');
        if (!(0, misc_1.isValidFileName)(name))
            return ctx.status = cross_1.HTTP_BAD_REQUEST;
        if ((0, vfs_1.statusCodeForMissingPerm)(parentNode, 'can_upload', ctx)) {
            if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
                setWebdavHeaders(true);
            return;
        }
        try {
            await (0, promises_1.mkdir)((0, path_1.join)(parentNode.source, name));
            return ctx.status = cross_1.HTTP_CREATED;
        }
        catch (e) {
            return ctx.status = cross_1.HTTP_SERVER_ERROR;
        }
    }
    async function handleMove() {
        setWebdavHeaders();
        if (await isLocked(path, ctx))
            return;
        const node = await (0, vfs_1.urlToNode)(path, ctx);
        if (!node)
            return next();
        let dest = ctx.get('destination');
        const i = dest.indexOf('//');
        if (i >= 0)
            dest = dest.slice(dest.indexOf('/', i + 2));
        dest = (0, cross_1.join)(ctx.state.root || '', dest); // on Windows, we must use / as the delimiter to be able to compare with `path` below
        if (await isLocked(dest, ctx))
            return;
        if ((0, path_1.dirname)(path) === (0, path_1.dirname)(dest)) // rename case. `path` is is encoded, so we test before decoding `dest`
            try {
                // decode the single path segment so reserved chars like %2C become their real name on rename
                const newName = (0, cross_1.safeDecodeURIComponent)((0, path_1.basename)(dest), '');
                if (!newName)
                    return ctx.status = cross_1.HTTP_BAD_REQUEST;
                await (0, frontEndApis_1.requestedRename)(node, newName, ctx);
                releaseWebdavLock(path); // RFC 4918 says MOVE must not carry locks to destination, so clear source lock on success
                return ctx.status = cross_1.HTTP_CREATED;
            }
            catch (e) {
                return ctx.status = e.status || cross_1.HTTP_SERVER_ERROR;
            }
        const moveRes = await (0, frontEndApis_1.moveFiles)([path], (0, path_1.dirname)(dest), ctx);
        if (moveRes instanceof Error)
            return ctx.status = moveRes.status || cross_1.HTTP_SERVER_ERROR;
        const err = moveRes?.errors?.[0];
        if (!err)
            releaseWebdavLock(path); // successful MOVE leaves the old path invalid, therefore its lock must be dropped
        return ctx.status = !err ? cross_1.HTTP_CREATED : typeof err === 'number' ? err : cross_1.HTTP_SERVER_ERROR;
    }
    async function handleDelete() {
        setWebdavHeaders();
        if (await isLocked(path, ctx))
            return;
        await next();
        if (ctx.status === cross_1.HTTP_OK)
            releaseWebdavLock(path); // webdav clients may forget UNLOCK; successful delete must clear any lock
    }
    async function handleUnlock() {
        setWebdavHeaders();
        const x = ctx.get(TOKEN_HEADER).slice(1, -1);
        const lock = locks.get(path);
        if (x !== lock?.token)
            return ctx.status = cross_1.HTTP_BAD_REQUEST;
        // with force_webdav_login disabled a client may silently fall back to anonymous; keep lock ownership on the original username
        if (!isSameLockUsername(lock, ctx))
            return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
        releaseWebdavLock(path);
        ctx.set(TOKEN_HEADER, x);
        if (const_1.IS_MAC)
            (0, vfs_1.urlToNode)(path, ctx).then(x => x?.source && dotClean((0, path_1.dirname)(x.source)));
        return ctx.status = cross_1.HTTP_NO_CONTENT;
    }
    async function handleLock() {
        setWebdavHeaders();
        const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await (0, consumers_1.text)(ctx.req) : '';
        const token = getProvidedLockToken();
        let seconds = Number(ctx.get('timeout').split(',').find(x => /^Second-\d+$/i.test(x.trim()))?.trim().split('-', 2)[1]);
        seconds = lodash_1.default.clamp(seconds || LOCK_DEFAULT_SECONDS, 1, LOCK_MAX_SECONDS);
        if (!body) {
            // Finder and similar clients refresh an existing lock by sending LOCK without a body
            if (!token)
                return ctx.status = cross_1.HTTP_BAD_REQUEST;
            const lock = locks.get(path);
            if (token !== lock?.token)
                return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
            // same-token refresh from another username would make abandoned locks effectively persistent
            if (!isSameLockUsername(lock, ctx))
                return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
            // refresh lock - keep the same token on refresh so clients can continue using the lock they already hold
            clearTimeout(lock.timeout);
            lock.timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000);
            lock.seconds = seconds;
            locks.set(path, lock);
            ctx.set(TOKEN_HEADER, lock.token);
            ctx.body = renderLockResponse(lock.token, lock.seconds);
            return;
        }
        const lockinfo = (0, cross_1.try_)(() => xmlParser.parse(body).lockinfo);
        const scope = lodash_1.default.keys(lockinfo?.lockscope)[0];
        const type = lodash_1.default.keys(lockinfo?.locktype)[0];
        if (!scope || !type)
            return ctx.status = cross_1.HTTP_BAD_REQUEST;
        if (ctx.get('depth') && ctx.get('depth') !== '0')
            return ctx.status = cross_1.HTTP_CONFLICT;
        if (scope !== 'exclusive' || type !== 'write')
            return ctx.status = cross_1.HTTP_CONFLICT;
        if (locks.has(path))
            return ctx.status = cross_1.HTTP_LOCKED;
        const newToken = 'urn:uuid:' + (0, node_crypto_1.randomUUID)();
        const timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000);
        locks.set(path, { token: newToken, timeout, seconds, username: getWebdavUsername(ctx) });
        ctx.set(TOKEN_HEADER, newToken);
        ctx.body = renderLockResponse(newToken, seconds);
    }
    async function handlePropfind() {
        setWebdavHeaders();
        const node = await (0, vfs_1.urlToNode)(path, ctx);
        if (!node)
            return next();
        let depth = Number(ctx.get('depth'));
        depth = isNaN(depth) ? Infinity : depth;
        const isList = depth !== 0;
        if ((0, vfs_1.statusCodeForMissingPerm)(node, isList ? 'can_list' : 'can_see', ctx)) {
            if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
                setWebdavHeaders(true);
            return;
        }
        ctx.type = 'xml';
        ctx.status = 207;
        const outPath = webdavHrefPath(path, node, ctx);
        const res = ctx.body = new stream_1.PassThrough({ encoding: 'utf8' });
        res.write(`<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:">`);
        await sendEntry(node);
        if (isList) {
            depth = Math.max(0, depth - 1);
            for await (const n of (0, vfs_1.walkNode)(node, { ctx, depth }))
                await sendEntry(n, true);
        }
        res.write(`</multistatus>`);
        res.end();
        async function sendEntry(node, append = false) {
            if ((0, vfs_1.nodeIsLink)(node))
                return;
            const name = (0, vfs_1.getNodeName)(node);
            const isDir = await (0, vfs_1.nodeIsFolder)(node);
            const st = await (0, vfs_1.nodeStats)(node);
            res.write(`<response>
              <href>${lodash_1.default.escape(outPath + (append ? (0, cross_1.pathEncode)(name, true) + (isDir ? '/' : '') : ''))}</href>
              <propstat>
                <status>HTTP/1.1 200 OK</status>
                <prop>
                    ${(0, cross_1.prefix)('<getlastmodified>', st?.mtime?.toGMTString(), '</getlastmodified>')}
                    ${(0, cross_1.prefix)('<creationdate>', (st?.birthtime || st?.ctime)?.toISOString().replace(/\..*/, '-00:00'), '</creationdate>')}
                    ${isDir ? '<resourcetype><collection/></resourcetype>'
                : `<resourcetype/><getcontentlength>${st?.size}</getcontentlength>`}
                </prop>
              </propstat>
              </response>
            `);
        }
    }
    async function handleProppatch() {
        setWebdavHeaders();
        if (await isLocked(path, ctx))
            return;
        const node = await (0, vfs_1.urlToNode)(path, ctx);
        if (!node)
            return next();
        if ((0, vfs_1.statusCodeForMissingPerm)(node, 'can_see', ctx)) {
            if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
                setWebdavHeaders(true);
            return;
        }
        const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await (0, consumers_1.text)(ctx.req) : '';
        const props = (0, cross_1.try_)(() => parseProppatchProps(body)) || [];
        if (!props.length)
            return ctx.status = cross_1.HTTP_BAD_REQUEST;
        const statuses = [];
        for (const prop of props)
            statuses.push({ prop: prop.name, status: await applyProppatchProp(prop, node, path, ctx) });
        const outPath = webdavHrefPath(path, node, ctx);
        ctx.type = 'xml';
        ctx.status = 207;
        ctx.body = renderProppatchResponse(outPath, statuses);
    }
    function setWebdavHeaders(authenticate = false) {
        ctx.set('DAV', '1,2');
        ctx.set('MS-Author-Via', 'DAV');
        ctx.set('Allow', 'PROPFIND,PROPPATCH,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT');
        if (authenticate)
            ctx.set('WWW-Authenticate', cross_1.BASIC_AUTHENTICATE_HEADER);
    }
    function shouldChallengeWebdav() {
        if ((0, auth_1.getCurrentUsername)(ctx))
            return false;
        if (forceWebdavLogin.compiled()?.test(ua))
            return challengeWebdav();
        if (!webdavInitialAuth.compiled()?.test(ua))
            return false;
        if (ctx.get('authorization'))
            return challengeWebdav();
        const key = `${ctx.ip}|${ctx.host}|${ua || ''}`;
        if (webdavPrompted.has(key))
            return false;
        webdavPrompted.try(key, () => true);
        return challengeWebdav();
        function challengeWebdav() {
            setWebdavHeaders(true);
            ctx.status = cross_1.HTTP_UNAUTHORIZED;
            ctx.body = '';
            return true;
        }
    }
    function getProvidedLockToken() {
        const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '');
        if (direct)
            return direct;
        const ifHeader = ctx.get('If');
        return /<([^>]+)>/.exec(ifHeader)?.[1] || '';
    }
    function renderLockResponse(token, seconds) {
        return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
            <locktype><write/></locktype>
            <lockscope><exclusive/></lockscope>
            <locktoken><href>${lodash_1.default.escape(token)}</href></locktoken>
            <lockroot><href>${lodash_1.default.escape(path)}</href></lockroot>
            <depth>0</depth>
            <timeout>Second-${seconds}</timeout>
        </activelock></lockdiscovery></prop>`;
    }
};
exports.webdav = webdav;
function compileWebdavAgentRegex(v) {
    return !v ? null : v === true ? /.*/ : new RegExp(v.trim(), 'i');
}
function webdavAgentKey(ctx, ua) {
    // tying detection to source IP avoids promoting one spoofed UA to global WebDAV behavior
    return `${ctx.ip}|${ua}`;
}
function allowWebdavOverwrite(key) {
    canOverwrite.add(key);
    setTimeout(() => canOverwrite.delete(key), 10_000); // grace period
}
function webdavHrefPath(path, node, ctx) {
    const href = path.slice(Math.max(0, (ctx.state.root?.length ?? 0) - 1));
    // WebDAV clients use href shape to infer resource type, so file hrefs must not look like collections
    return (0, vfs_1.nodeIsFolder)(node) ? (0, cross_1.enforceFinal)('/', href) : (0, cross_1.removeFinal)('/', href);
}
function parseProppatchProps(body) {
    const doc = xmlParser.parse(body);
    const update = getXmlChildren(doc, 'propertyupdate')[0];
    if (!update)
        return [];
    const ret = [];
    for (const opName of ['set', 'remove'])
        for (const op of getXmlChildren(update, opName))
            for (const prop of getXmlChildren(op, 'prop'))
                for (const k of Object.keys(prop))
                    if (!k.startsWith('@_') && k !== '#text')
                        ret.push({ name: localXmlName(k), value: prop[k] });
    return lodash_1.default.uniqBy(ret, 'name');
}
async function applyProppatchProp(prop, node, path, ctx) {
    const k = prop.name.toLowerCase();
    if (PROPPATCH_PROTECTED_LIVE_PROPS.has(k))
        return cross_1.HTTP_FORBIDDEN;
    if (node.source && (PROPPATCH_UTIME_PROPS.has(k) || const_1.IS_WINDOWS && k === 'win32fileattributes')) {
        // WebDAV clients patch metadata right after upload; outside that short same-username grace, metadata writes are file modifications
        const missingWritePerm = canOverwrite.has(path + (0, cross_1.prefix)('|', (0, auth_1.getCurrentUsername)(ctx))) ? 0
            : (0, vfs_1.statusCodeForMissingPerm)(node, 'can_delete', ctx, false);
        if (missingWritePerm)
            return missingWritePerm;
    }
    if (node.source && PROPPATCH_UTIME_PROPS.has(k)) {
        const date = new Date(String(prop.value));
        if (isNaN(Number(date)))
            return cross_1.HTTP_BAD_REQUEST;
        const stats = await (0, vfs_1.nodeStats)(node);
        const atime = k === 'win32lastaccesstime' ? date : stats?.atime ?? new Date();
        const mtime = k === 'win32lastmodifiedtime' ? date : stats?.mtime ?? new Date();
        // WebDAV clients often use dead properties for file times; apply the portable subset instead of only pretending success
        await (0, promises_1.utimes)(node.source, atime, mtime);
    }
    if (node.source && const_1.IS_WINDOWS && k === 'win32fileattributes') {
        const attributes = parseWindowsFileAttributes(prop.value);
        if (attributes === undefined)
            return cross_1.HTTP_BAD_REQUEST;
        // fswin is already our Windows attribute bridge; this keeps PROPPATCH metadata aligned with the actual filesystem
        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))));
        if (!ok)
            return cross_1.HTTP_SERVER_ERROR;
    }
    // PROPPATCH is only persisted when HFS gets real dead-property storage; no-op success keeps Windows and macOS clients from aborting writes
    return cross_1.HTTP_OK;
}
function parseWindowsFileAttributes(v) {
    const s = String(v).trim();
    if (!s)
        return;
    const n = Number(/^0x/i.test(s) || /^[0-9a-f]{8}$/i.test(s) ? '0x' + s.replace(/^0x/i, '') : s);
    if (!Number.isInteger(n) || n < 0)
        return;
    return n;
}
function renderProppatchResponse(path, statuses) {
    const byStatus = lodash_1.default.groupBy(statuses, 'status');
    return `<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:"><response>
        <href>${lodash_1.default.escape(path)}</href>
        ${lodash_1.default.map(byStatus, (items, status) => `<propstat>
            <prop>${items.map(({ prop }) => `<${prop}/>`).join('')}</prop>
            <status>HTTP/1.1 ${status} ${lodash_1.default.escape(cross_1.HTTP_MESSAGES[Number(status)] || http_1.STATUS_CODES[Number(status)] || '')}</status>
        </propstat>`).join('')}
    </response></multistatus>`;
}
function getXmlChildren(obj, name) {
    if (!obj || typeof obj !== 'object')
        return [];
    return Object.entries(obj).flatMap(([k, v]) => localXmlName(k) === name ? (0, cross_1.wantArray)(v) : []);
}
function localXmlName(name) {
    return name.split(':').at(-1) || name;
}
// Finder will upload special attributes as files with name ._* that can be merged using system utility "dot_clean"
const cleaners = {};
function dotClean(path) {
    (0, cross_1.getOrSet)(cleaners, path, () => setTimeout(() => {
        try {
            (0, child_process_1.exec)('dot_clean .', { cwd: path }, (err, out) => done(err || out));
        }
        catch (e) {
            done(e);
        }
        function done(log) {
            console.debug('dot_clean', path, log);
            delete cleaners[path];
        }
    }, 10_000));
}
