import type { ParsedIfHeader } from "@/worker/dav/if-header"; import { isSameOrDescendantPath, normalizeFilesPath } from "@/worker/dav/paths"; import type { FileLockRow } from "@/worker/db/file-dav-do/schema"; import type { LockScope } from "@/worker/db/types"; export function normalizeLockToken(token: string): string { return token.startsWith("<") && token.endsWith(">") ? token.slice(1, -1) : token; } function hasLockToken(tokens: string[], token: string): boolean { const wanted = normalizeLockToken(token); return tokens.map(normalizeLockToken).includes(wanted); } export function lockAppliesToHref(lock: FileLockRow, href: string): boolean { if (lock.rootPath === href) return true; return lock.depth === "infinity" && isSameOrDescendantPath(href, lock.rootPath); } function lockOverlapsHref(lock: FileLockRow, href: string): boolean { return lockAppliesToHref(lock, href) || isSameOrDescendantPath(lock.rootPath, href); } export function findLockConflict( locks: FileLockRow[], href: string, submittedTokens: string[], requestedScope: LockScope | null, ): FileLockRow | null { for (const lock of locks) { if (!lockOverlapsHref(lock, href)) continue; if (hasLockToken(submittedTokens, lock.token)) continue; if (requestedScope === null || requestedScope === "exclusive" || lock.scope === "exclusive") return lock; } return null; } export function findLockConflictOnResource( locks: FileLockRow[], href: string, submittedTokens: string[], requestedScope: LockScope | null, ): FileLockRow | null { for (const lock of locks) { if (!lockAppliesToHref(lock, href)) continue; if (hasLockToken(submittedTokens, lock.token)) continue; if (requestedScope === null || requestedScope === "exclusive" || lock.scope === "exclusive") return lock; } return null; } // RFC 4918 6.1 / 7.3: a new LOCK request must not bypass conflicts even if the // requester supplies an existing lock token. Tokens authorize writes against an // existing lock; they do not authorize the creation of an overlapping new lock. export function findNewLockConflict(locks: FileLockRow[], href: string, requestedScope: LockScope): FileLockRow | null { for (const lock of locks) { if (!lockOverlapsHref(lock, href)) continue; if (requestedScope === "exclusive" || lock.scope === "exclusive") return lock; } return null; } function normalizeIfResourceHref(href: string): string { let pathname = href; try { pathname = new URL(href).pathname; } catch { // Tagged If headers may also carry origin-form hrefs such as /files/a.txt. } const normalized = normalizeFilesPath(pathname); return normalized.ok ? normalized.path.href : pathname; } function lockTokenConditionMatches(locks: FileLockRow[], href: string, token: string): boolean { const normalized = normalizeLockToken(token); return locks.some((lock) => lock.token === normalized && lockAppliesToHref(lock, href)); } export function ifHeaderMatches(input: { header: ParsedIfHeader; targetHref: string; targetEtag: string | null; locks: FileLockRow[]; getEtag?: (href: string) => string | null; }): boolean { if (input.header.lists.length === 0) return true; for (const list of input.header.lists) { const conditionHref = list.resourceHref ? normalizeIfResourceHref(list.resourceHref) : input.targetHref; const listMatches = list.conditions.every((condition) => { const matches = condition.kind === "etag" ? (conditionHref === input.targetHref ? input.targetEtag : (input.getEtag?.(conditionHref) ?? null)) === condition.value : condition.kind === "lock-token" ? lockTokenConditionMatches(input.locks, conditionHref, condition.value) : false; return condition.not ? !matches : matches; }); if (listMatches) return true; } return false; }