fix(docsync): an idle WebDAV binding read as an emptied folder, and two ids nobody checked

The audit's worst find, reproduced against a live Nextcloud before and after the
fix: the WebDAV adapters answer "nothing changed here" by returning an EMPTY
listing plus `cursorUnchanged`, and nothing read that flag — `grep -rn
cursorUnchanged src/` found it set by four adapters and consulted by none. The
core therefore saw a folder that had lost everything. Run 1 and 2 look fine
because the run's own uploads move the ETag; from run 3 on, every run of an
idle binding ends `partial` with `mass_delete_guard` and six documents reported
missing, forever, until failure_count opens the circuit and the scheduler drops
the binding entirely. That is the ordinary case — a binding nobody is touching.
planReconcile now takes `remoteUnchanged` and plans only what happened in TREK.

Two ids were authorised by trip and never checked against it:

  - resolveConflict took an item id on trust, so the owner of any trip could
    decide a conflict belonging to somebody else's. Both are plain integers and
    nothing compared them.
  - The connection test merged the STORED credential into a probe of whatever
    baseUrl the caller named — a way to have TREK post another account's API
    token at a server of the caller's choosing. Stored secrets are now reused
    only for the address they were stored against.

The phone's connect form was wrong in three ways at once, each of which the
desktop had right: it read the address as `baseUrl` while the field is
`base_url` (so every connection it saved carried an empty address), it drew
`allow_insecure_tls` as a text box and always sent false, and it built label
keys as `docsync.provider${cap(label)}` when the label column already holds the
full suffix — `docsync.providerProviderApiKey`, rendered raw on screen. Those
rules are not markup, so they now live in `useConnectForm`, which both shells
use. Its test fixture invented the field shape and so agreed with the broken
sheet; it now mirrors document_provider_fields.

Also: the scheduler read its interval once at bootstrap while this file's own
comment promised a per-tick read, so changing it did nothing until a restart;
and dueLinks ordered by a column that a successful run clears, which left every
binding tied and SQLite answering in insertion order — on a database with 170
bindings the same twenty ran on every tick and nothing created after them was
ever synced automatically.
This commit is contained in:
Maurice
2026-09-19 00:07:33 +02:00
parent 1430056d24
commit 4fa06c3483
12 changed files with 608 additions and 122 deletions
+26 -3
View File
@@ -74,7 +74,17 @@ export class DocSyncService {
// ── Entry points ───────────────────────────────────────────────────────────
/** Links the scheduler should look at now. */
/**
* Links the scheduler should look at now, longest-waiting first.
*
* The second sort key is what makes the limit fair. A successful run clears
* `next_attempt_at`, so on a healthy instance every binding sorts equal on the
* first key and SQLite falls back to insertion order — which means the twenty
* oldest bindings were picked on every tick and everything created after them
* was never synced automatically at all. Found on a dev database with 170
* bindings, where a freshly created one was still untouched minutes later
* while the first twenty ran again and again.
*/
dueLinks(limit = 20): LinkRow[] {
return this.db.connection
.prepare(
@@ -82,7 +92,9 @@ export class DocSyncService {
WHERE sync_enabled = 1
AND failure_count < ?
AND (next_attempt_at IS NULL OR next_attempt_at <= CURRENT_TIMESTAMP)
ORDER BY COALESCE(next_attempt_at, '1970-01-01') ASC
ORDER BY COALESCE(next_attempt_at, '1970-01-01') ASC,
COALESCE(last_sync_at, '1970-01-01') ASC,
id ASC
LIMIT ?`,
)
.all(LINK_CIRCUIT_OPEN_AFTER, limit) as LinkRow[];
@@ -161,6 +173,7 @@ export class DocSyncService {
local,
direction: link.direction as 'both' | 'pull' | 'push',
remoteTruncated: listing.data.truncated,
remoteUnchanged: listing.data.cursorUnchanged === true,
stableRemoteIds: provider.capabilities(ref).stableId,
maxAttempts: ITEM_MAX_ATTEMPTS,
});
@@ -659,11 +672,21 @@ export class DocSyncService {
* overwriting either side, which is the only outcome that cannot lose work
* and is therefore what the UI offers first.
*/
async resolveConflict(itemId: number, keep: 'trek' | 'provider' | 'both'): Promise<boolean> {
/**
* Decide a conflict.
*
* `tripId` is not decoration: the route authorises the caller against the trip
* in its URL, and without checking the row against the same trip an owner of
* any trip could resolve a conflict in somebody else's — the id is a plain
* integer and nothing else tied the two together. Same rule as everywhere in
* this codebase: every referenced id must exist AND belong to the same trip.
*/
async resolveConflict(itemId: number, keep: 'trek' | 'provider' | 'both', tripId?: number): Promise<boolean> {
const item = this.db.connection
.prepare('SELECT * FROM document_sync_items WHERE id = ?')
.get(itemId) as Record<string, unknown> | undefined;
if (!item || item.state !== 'conflict') return false;
if (tripId !== undefined && Number(item.trip_id) !== Number(tripId)) return false;
const link = this.config.getLink(Number(item.link_id));
if (!link) return false;