Skip to content
File

Blob: src/worker/dav/locks.ts

typescript106 lines
1import type { ParsedIfHeader } from "@/worker/dav/if-header";
2import { isSameOrDescendantPath, normalizeFilesPath } from "@/worker/dav/paths";
3import type { FileLockRow } from "@/worker/db/file-dav-do/schema";
4import type { LockScope } from "@/worker/db/types";
5 
6export function normalizeLockToken(token: string): string {
7 return token.startsWith("<") && token.endsWith(">") ? token.slice(1, -1) : token;
8}
9 
10function hasLockToken(tokens: string[], token: string): boolean {
11 const wanted = normalizeLockToken(token);
12 return tokens.map(normalizeLockToken).includes(wanted);
13}
14 
15export function lockAppliesToHref(lock: FileLockRow, href: string): boolean {
16 if (lock.rootPath === href) return true;
17 return lock.depth === "infinity" && isSameOrDescendantPath(href, lock.rootPath);
18}
19 
20function lockOverlapsHref(lock: FileLockRow, href: string): boolean {
21 return lockAppliesToHref(lock, href) || isSameOrDescendantPath(lock.rootPath, href);
22}
23 
24export function findLockConflict(
25 locks: FileLockRow[],
26 href: string,
27 submittedTokens: string[],
28 requestedScope: LockScope | null,
29): FileLockRow | null {
30 for (const lock of locks) {
31 if (!lockOverlapsHref(lock, href)) continue;
32 if (hasLockToken(submittedTokens, lock.token)) continue;
33 if (requestedScope === null || requestedScope === "exclusive" || lock.scope === "exclusive") return lock;
34 }
35 return null;
36}
37 
38export function findLockConflictOnResource(
39 locks: FileLockRow[],
40 href: string,
41 submittedTokens: string[],
42 requestedScope: LockScope | null,
43): FileLockRow | null {
44 for (const lock of locks) {
45 if (!lockAppliesToHref(lock, href)) continue;
46 if (hasLockToken(submittedTokens, lock.token)) continue;
47 if (requestedScope === null || requestedScope === "exclusive" || lock.scope === "exclusive") return lock;
48 }
49 return null;
50}
51 
52// RFC 4918 6.1 / 7.3: a new LOCK request must not bypass conflicts even if the
53// requester supplies an existing lock token. Tokens authorize writes against an
54// existing lock; they do not authorize the creation of an overlapping new lock.
55export function findNewLockConflict(locks: FileLockRow[], href: string, requestedScope: LockScope): FileLockRow | null {
56 for (const lock of locks) {
57 if (!lockOverlapsHref(lock, href)) continue;
58 if (requestedScope === "exclusive" || lock.scope === "exclusive") return lock;
59 }
60 return null;
61}
62 
63function normalizeIfResourceHref(href: string): string {
64 let pathname = href;
65 try {
66 pathname = new URL(href).pathname;
67 } catch {
68 // Tagged If headers may also carry origin-form hrefs such as /files/a.txt.
69 }
70 
71 const normalized = normalizeFilesPath(pathname);
72 return normalized.ok ? normalized.path.href : pathname;
73}
74 
75function lockTokenConditionMatches(locks: FileLockRow[], href: string, token: string): boolean {
76 const normalized = normalizeLockToken(token);
77 return locks.some((lock) => lock.token === normalized && lockAppliesToHref(lock, href));
78}
79 
80export function ifHeaderMatches(input: {
81 header: ParsedIfHeader;
82 targetHref: string;
83 targetEtag: string | null;
84 locks: FileLockRow[];
85 getEtag?: (href: string) => string | null;
86}): boolean {
87 if (input.header.lists.length === 0) return true;
88 
89 for (const list of input.header.lists) {
90 const conditionHref = list.resourceHref ? normalizeIfResourceHref(list.resourceHref) : input.targetHref;
91 const listMatches = list.conditions.every((condition) => {
92 const matches =
93 condition.kind === "etag"
94 ? (conditionHref === input.targetHref ? input.targetEtag : (input.getEtag?.(conditionHref) ?? null)) ===
95 condition.value
96 : condition.kind === "lock-token"
97 ? lockTokenConditionMatches(input.locks, conditionHref, condition.value)
98 : false;
99 return condition.not ? !matches : matches;
100 });
101 if (listMatches) return true;
102 }
103 
104 return false;
105}