Add Nextcloud connector; reject VFS as a transfer mechanism

Investigated using the Nextcloud desktop client's Virtual Files as a
cache instead of talking to the server directly. Measured on this
machine (client 4.0.7): the configured folder holds 121,785 placeholders
against 10,267 materialised files, including 7,037 CR2 and 9,411 DNG.

Three findings, each independently disqualifying:

  - Linux VFS is *suffix* mode. A dehydrated IMG.CR2 exists only as
    IMG.CR2.nextcloud holding one byte; the real name is absent.
  - Reading a placeholder does not hydrate it. dd of the first 256KB
    returned 1 byte, the stub was unchanged, and the real name never
    appeared. There is no FUSE layer — the stub is an inert marker.
  - Even with hydration the granularity is wrong: VFS has two states,
    1 byte or all bytes, and the preview tier needs a ~256KB prefix of
    a 27MB file. That is ~100x what FR-NC-3 requires.

Recorded as ARCH §9.0. Coexistence is still supported: dr-types now
recognises *.nextcloud stubs, and the viewer lists them as "not
downloaded" rather than as corrupt files or not at all.

So the connector talks to the server directly, as D7 specified.
Implemented: Login Flow v2, PROPFIND with oc:fileid and nc:has-preview,
ETag pruning via a Depth:0 probe, range GET with local slicing when the
server ignores the header, conditional PUT, and /core/preview with
forceIcon=false. delta() returns Unsupported and says why.

Chunked upload v2 is not implemented yet — put() rejects bodies over
5MB explicitly rather than silently truncating.

Two bugs found by testing: my hand-computed epoch in a date test was a
day out (the parser was right), and quick-xml reaches EOF on truncated
input without erroring, so unbalanced elements needed an explicit check
— a half-parsed multistatus must not look like an empty directory.

83 tests passing.
This commit is contained in:
2026-08-09 09:09:27 +02:00
parent 9717e59909
commit f8a718f42e
11 changed files with 1881 additions and 23 deletions
+30
View File
@@ -664,6 +664,36 @@ the sidecar-authoritative model (ARCH §6.12) buys.
Sync decides *what changed*. Caching policy decides *what is kept locally* — a separate concern, and
the one that determines whether the app is usable on a tablet with a 2 TB library behind it.
### 9.0 Why not the Nextcloud client's Virtual Files
An appealing shortcut: sync the library with the official desktop client in VFS mode and let
DarkRoom read ordinary paths, inheriting their sync engine, upload, and conflict handling for free.
**Measured on this machine (client 4.0.7, 2026-08-09) and rejected.** The configured folder uses
`virtualFilesMode=suffix`, the only mode Linux supports, holding 121,785 placeholders against
10,267 materialised files — including 7,037 CR2 and 9,411 DNG placeholders.
Three findings, each independently disqualifying:
1. **A dehydrated file exists only under a different name.** `IMG.CR2` is absent; only
`IMG.CR2.nextcloud` exists, containing exactly one byte. Any extension-based scan sees
`.nextcloud`, so the app needs placeholder-aware code regardless — VFS is not transparent.
2. **Reads do not hydrate.** Reading the stub returns its 1 byte and nothing else; no fetch is
triggered, the stub is unchanged, and the real name never appears. Suffix mode is an inert
marker, not a filesystem hook — there is no FUSE layer intercepting reads. Hydration happens
only when the *client* is instructed to sync that file. **DarkRoom cannot read through a
placeholder at all.**
3. **Even with hydration, granularity is wrong.** VFS has two states, 1 byte or all bytes. The
preview tier — the one that makes remote browsing viable on mobile data — needs a ~256 KB prefix
of a 27 MB file. A hydrating VFS would transfer ~100× what FR-NC-3 requires, which is precisely
the cost range extraction exists to avoid.
Coexistence is still fine and worth supporting: a user may keep a VFS-synced folder, and DarkRoom
should recognise `*.nextcloud` stubs and report those images as `Availability::Offline` (FR-NC-6c)
rather than as corrupt files. What it must not do is depend on VFS for transfer.
### 9.1 The three tiers, restated as policy
| Tier | Content | Default |
+13 -13
View File
@@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value |
|---|---|
| Source files scanned | 20 |
| TRACES tags found | 24 |
| Source files scanned | 23 |
| TRACES tags found | 30 |
| Requirements defined | 143 |
| Requirements covered | 29 |
| **Coverage** | **20.3%** (29/143) |
| Requirements covered | 32 |
| **Coverage** | **22.4%** (32/143) |
### By type
| Type | Covered | Defined |
|---|---|---|
| FR | 20 | 90 |
| FR | 23 | 90 |
| NFR | 7 | 47 |
| R | 2 | 6 |
@@ -37,17 +37,20 @@ _None._
| FR-CAT-1a | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) |
| FR-CAT-2 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) |
| FR-CAT-5 | [`core/dr-decode/src/lib.rs:125`](../core/dr-decode/src/lib.rs#L125) |
| FR-CAT-9 | [`core/dr-types/src/lib.rs:80`](../core/dr-types/src/lib.rs#L80) |
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:96`](../core/dr-decode/src/preview.rs#L96) |
| FR-CULL-2 | [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123) |
| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:119`](../core/dr-gpu/src/lib.rs#L119) |
| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:21`](../ui/dr-ui/src/lib.rs#L21) |
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:151`](../core/dr-decode/src/lib.rs#L151) |
| FR-NC-12 | [`core/dr-sync/src/lib.rs:127`](../core/dr-sync/src/lib.rs#L127), [`core/dr-sync/src/lib.rs:33`](../core/dr-sync/src/lib.rs#L33) |
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44) |
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:30`](../core/dr-sync-nextcloud/src/lib.rs#L30), [`core/dr-sync/src/lib.rs:128`](../core/dr-sync/src/lib.rs#L128), [`core/dr-sync/src/lib.rs:34`](../core/dr-sync/src/lib.rs#L34) |
| FR-NC-3 | [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41) |
| FR-NC-4 | [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:127`](../core/dr-sync/src/lib.rs#L127) |
| FR-NC-6c | [`core/dr-types/src/lib.rs:111`](../core/dr-types/src/lib.rs#L111) |
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:128`](../core/dr-sync/src/lib.rs#L128) |
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) |
| FR-NC-6c | [`core/dr-types/src/lib.rs:131`](../core/dr-types/src/lib.rs#L131), [`core/dr-types/src/lib.rs:80`](../core/dr-types/src/lib.rs#L80) |
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) |
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:83`](../core/dr-decode/src/lib.rs#L83), [`core/dr-types/src/lib.rs:70`](../core/dr-types/src/lib.rs#L70) |
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:83`](../core/dr-decode/src/lib.rs#L83), [`core/dr-types/src/lib.rs:90`](../core/dr-types/src/lib.rs#L90) |
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:151`](../core/dr-decode/src/lib.rs#L151) |
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:62`](../core/dr-decode/src/lib.rs#L62) |
@@ -65,7 +68,7 @@ _None._
## Not yet tagged
114 of 143 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
111 of 143 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
<details><summary>Show untagged requirements</summary>
@@ -79,7 +82,6 @@ _None._
- FR-CAT-6
- FR-CAT-7
- FR-CAT-8
- FR-CAT-9
- FR-CULL-3
- FR-CULL-4
- FR-CULL-5
@@ -114,11 +116,9 @@ _None._
- FR-EXP-6
- FR-EXP-7
- FR-EXP-8
- FR-NC-1
- FR-NC-10
- FR-NC-11
- FR-NC-2
- FR-NC-5
- FR-NC-6
- FR-NC-6a
- FR-NC-6b