Skip to content

Commit f24d80a

Browse files
committed
fix(sync): treat an oversized WebDAV fence response as an absent fence so encryption transitions can start (#1113)
1 parent 57be533 commit f24d80a

4 files changed

Lines changed: 47 additions & 4 deletions

File tree

docs/release-notes/unreleased.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,4 @@ Changes collected after `v1.2.1` and before the next version tag.
171171
- Android File Sync: on document providers that decorate new file names (typically appending ".bin" to the temporary write files), every sync failed with "was created or renamed by another writer" and kept requeueing. The temporary file is now renamed back to its exact name instead of being treated as another device's write; real concurrent writes are still detected. (#1113)
172172
- Desktop: the priority color strip on task rows now sits in the row's left padding instead of directly against the checkbox, matching mobile's spacing.
173173
- Sync encryption setup: after adding a sync location that already holds encrypted data, the Encryption section now switches to "enter your existing passphrase" as soon as the first sync or connection test discovers it, instead of still offering to set a new passphrase until an enable attempt failed. Desktop and mobile. (#1001)
174+
- Sync encryption could not be enabled (or disabled) on WebDAV servers that answer a request for a missing file with a web page instead of "not found" (reported with Koofr): the transition stopped at "Response exceeds the 4096 byte download limit" before doing anything. Such a response is now treated as the file being absent; protection against two devices transitioning at once is unchanged. (#1113)

packages/core/src/sync-remote-fence-providers.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,26 @@ describe('remote mutation fence provider ports', () => {
7777
});
7878
});
7979
});
80+
81+
describe('oversized fence responses (#1113)', () => {
82+
it('reads a non-404 response too large to be a fence as an absent file', async () => {
83+
// Koofr answers the GET for a missing file with a large HTML page instead
84+
// of 404; before the fix this rejected with ResponseTooLargeError and no
85+
// encryption transition could start.
86+
const hugeHtml = `<html>${'x'.repeat(8_192)}</html>`;
87+
const fetcher = vi.fn(async () => new Response(hugeHtml, {
88+
status: 200,
89+
headers: { date: SERVER_DATE, 'content-type': 'text/html' },
90+
})) as unknown as typeof fetch;
91+
const port = createWebdavSyncRemoteMutationFencePort(
92+
'https://dav.example/root/data.json',
93+
{ fetcher },
94+
);
95+
96+
await expect(port.read()).resolves.toEqual({
97+
bytes: null,
98+
version: null,
99+
serverNowMs: Date.parse(SERVER_DATE),
100+
});
101+
});
102+
});

packages/core/src/sync-remote-fence-providers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export const createWebdavSyncRemoteMutationFencePort = (
3333
options: WebDavOptions = {},
3434
): SyncRemoteMutationFencePort => {
3535
const url = webdavMutationFenceUrl(documentUrl);
36-
const readOptions: WebDavOptions = { ...options, maxBytes: FENCE_MAX_BYTES };
36+
// A response bigger than a fence record cannot be one. Koofr answers the GET for a
37+
// missing file with a large HTML page instead of 404 (#1113); reading it as absent
38+
// keeps acquisition safe because the follow-up write is create-only conditional.
39+
const readOptions: WebDavOptions = { ...options, maxBytes: FENCE_MAX_BYTES, treatOversizeAsAbsent: true };
3740
return {
3841
read: () => webdavGetFileVersionedWithServerTime(url, readOptions),
3942
write: (bytes, expectedVersion) => webdavPutFileVersioned(

packages/core/src/webdav.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ResponseTooLargeError,
23
DEFAULT_TIMEOUT_MS,
34
assertConnectionAllowed,
45
createProgressStream,
@@ -40,6 +41,12 @@ export interface WebDavOptions {
4041
/** Internal one-shot write mode. A legacy plaintext document without a usable
4142
* generation cannot safely retry after an ambiguous response. */
4243
disableParentCollectionRetry?: boolean;
44+
/** Fence reads only: a body larger than maxBytes cannot be a fence record, and some
45+
* servers (Koofr, #1113) answer the GET for a missing file with a large HTML page
46+
* instead of 404. Reports such a response as an absent file; the caller's create-only
47+
* conditional write still guards against a real racing peer. Never set this for a
48+
* document read - an oversized document must fail, not read as absent. */
49+
treatOversizeAsAbsent?: boolean;
4350
}
4451

4552
export type RemoteFileMetadata = {
@@ -939,13 +946,22 @@ export async function webdavGetFileVersionedWithServerTime(
939946
(error as { status?: number }).status = res.status;
940947
throw error;
941948
}
942-
return {
943-
bytes: new Uint8Array(await readResponseBody(
949+
let body: ArrayBuffer;
950+
try {
951+
body = await readResponseBody(
944952
res,
945953
options.onProgress,
946954
options.maxBytes ?? MAX_DOWNLOAD_BYTES,
947955
signal,
948-
)),
956+
);
957+
} catch (error) {
958+
if (options.treatOversizeAsAbsent && error instanceof ResponseTooLargeError) {
959+
return { bytes: null, version: null, serverNowMs };
960+
}
961+
throw error;
962+
}
963+
return {
964+
bytes: new Uint8Array(body),
949965
version: normalizeStrongWebdavEtag(res.headers.get('etag')),
950966
serverNowMs,
951967
};

0 commit comments

Comments
 (0)