export interface NormalizedDavPath { href: string; segments: string[]; collectionHint: boolean; } export type PathNormalizeResult = | { ok: true; path: NormalizedDavPath } | { ok: false; status: number; message: string }; export type CollectionObjectPath = | { kind: "home" } | { kind: "collection"; collectionName: string } | { kind: "object"; collectionName: string; objectName: string }; function hasEncodedSlash(segment: string): boolean { return /%2f/i.test(segment) || /%5c/i.test(segment); } function encodeSegment(segment: string): string { return encodeURIComponent(segment).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`); } export function normalizeFilesPath(pathname: string): PathNormalizeResult { if (pathname !== "/files" && pathname !== "/files/" && !pathname.startsWith("/files/")) { return { ok: false, status: 404, message: "Not found" }; } const rawRemainder = pathname === "/files" ? "" : pathname.slice("/files/".length); const collectionHint = pathname === "/files" || pathname.endsWith("/"); const segments: string[] = []; for (const rawSegment of rawRemainder.split("/")) { if (!rawSegment) continue; if (hasEncodedSlash(rawSegment)) return { ok: false, status: 400, message: "Encoded slashes are not allowed" }; let segment: string; try { segment = decodeURIComponent(rawSegment); } catch { return { ok: false, status: 400, message: "Invalid percent encoding" }; } if (segment === "." || segment === "") continue; if (segment === "..") { segments.pop(); continue; } if (/[\u0000-\u001f\u007f]/u.test(segment)) { return { ok: false, status: 400, message: "Control characters are not allowed in DAV paths" }; } segments.push(segment); } const encoded = segments.map(encodeSegment).join("/"); const href = segments.length === 0 ? "/files/" : `/files/${encoded}${collectionHint ? "/" : ""}`; return { ok: true, path: { href, segments, collectionHint: collectionHint || segments.length === 0 } }; } function decodeCollectionSegment(raw: string): string | null { if (hasEncodedSlash(raw)) return null; try { const segment = decodeURIComponent(raw); if (/[\u0000-\u001f\u007f]/u.test(segment)) return null; return segment; } catch { return null; } } export function parseCollectionObjectPath(pathname: string, basePath: string): CollectionObjectPath | null { if (pathname === basePath || pathname === `${basePath}/`) return { kind: "home" }; if (!pathname.startsWith(`${basePath}/`)) return null; const trailingCollection = pathname.endsWith("/"); const parts: string[] = []; for (const rawPart of pathname.slice(`${basePath}/`.length).split("/")) { if (!rawPart) continue; const part = decodeCollectionSegment(rawPart); if (part === null) return null; if (part === ".") continue; if (part === "..") { parts.pop(); continue; } parts.push(part); } const [collectionName, objectName, extra] = parts as string[]; if (!collectionName || extra) return null; if (!objectName) return { kind: "collection", collectionName }; if (trailingCollection) return null; return { kind: "object", collectionName, objectName }; } export function parentHref(path: NormalizedDavPath): string | null { if (path.segments.length === 0) return null; const parentSegments = path.segments.slice(0, -1); if (parentSegments.length === 0) return "/files/"; return `/files/${parentSegments.map(encodeSegment).join("/")}/`; } export function basename(path: NormalizedDavPath): string | null { return path.segments[path.segments.length - 1] ?? null; } export function collectionHref(segments: string[]): string { if (segments.length === 0) return "/files/"; return `/files/${segments.map(encodeSegment).join("/")}/`; } export function resourceHref(segments: string[], collection: boolean): string { if (collection) return collectionHref(segments); return `/files/${segments.map(encodeSegment).join("/")}`; } export function isSameOrDescendantPath(candidateHref: string, ancestorHref: string): boolean { if (candidateHref === ancestorHref) return true; const base = ancestorHref.endsWith("/") ? ancestorHref : `${ancestorHref}/`; return candidateHref.startsWith(base); }