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
Generated
+657 -6
View File
@@ -452,6 +452,29 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "aws-lc-rs"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -760,6 +783,17 @@ dependencies = [
"libc",
]
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -802,6 +836,15 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "codespan-reporting"
version = "0.11.1"
@@ -897,6 +940,16 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -910,7 +963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics-types",
"foreign-types",
"libc",
@@ -923,7 +976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"libc",
]
@@ -942,6 +995,15 @@ version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -1191,6 +1253,23 @@ dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "dr-sync-nextcloud"
version = "0.1.0"
dependencies = [
"async-trait",
"dr-sync",
"dr-types",
"log",
"quick-xml",
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.20",
"tokio",
"url",
]
[[package]]
name = "dr-types"
version = "0.1.0"
@@ -1259,6 +1338,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.17.0"
@@ -1614,6 +1699,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.33"
@@ -1756,6 +1847,19 @@ dependencies = [
"unicode-width 0.2.2",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1775,8 +1879,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -2053,6 +2160,103 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
name = "i-slint-backend-linuxkms"
version = "1.17.1"
@@ -2641,6 +2845,12 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "ipnet"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -3169,6 +3379,12 @@ dependencies = [
"imgref",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lyon_algorithms"
version = "1.0.20"
@@ -3290,6 +3506,17 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
@@ -3975,6 +4202,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -4356,6 +4589,63 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases 0.2.2",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.3",
"rustls",
"socket2",
"thiserror 2.0.20",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases 0.2.2",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -4384,7 +4674,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -4394,7 +4695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@@ -4406,6 +4707,21 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -4439,7 +4755,7 @@ dependencies = [
"num-traits",
"paste",
"profiling",
"rand",
"rand 0.9.5",
"rand_chacha",
"simd_helpers",
"thiserror 2.0.20",
@@ -4632,6 +4948,46 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "reqwest"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
]
[[package]]
name = "resvg"
version = "0.47.0"
@@ -4658,6 +5014,20 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rle-decode-fast"
version = "1.0.3"
@@ -4751,6 +5121,81 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -4784,6 +5229,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -4815,6 +5269,29 @@ dependencies = [
"tiny-skia 0.11.4",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -5179,6 +5656,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "softbuffer"
version = "0.4.8"
@@ -5270,6 +5757,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.16.1"
@@ -5313,6 +5806,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -5537,6 +6039,55 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
@@ -5630,6 +6181,51 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.13.1",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.1.0"
@@ -5671,6 +6267,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -5791,6 +6393,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "unty"
version = "0.0.4"
@@ -5911,6 +6519,21 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -5975,6 +6598,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wayland-backend"
version = "0.3.16"
@@ -6146,6 +6782,15 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -6613,7 +7258,7 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases 0.2.2",
"concurrent-queue",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi",
@@ -6947,6 +7592,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.4"
+7 -1
View File
@@ -5,6 +5,7 @@ members = [
"core/dr-decode",
"core/dr-gpu",
"core/dr-sync",
"core/dr-sync-nextcloud",
"ui/dr-ui",
"apps/darkroom-desktop",
"tools/traceability",
@@ -23,6 +24,7 @@ dr-types = { path = "core/dr-types" }
dr-decode = { path = "core/dr-decode" }
dr-gpu = { path = "core/dr-gpu" }
dr-sync = { path = "core/dr-sync" }
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
dr-ui = { path = "ui/dr-ui" }
# GPU + UI
@@ -39,7 +41,11 @@ pollster = "0.4"
# Networking — no mature Nextcloud crate exists; the connector is hand-rolled
# over reqwest (D7). reqwest_dav was evaluated and is too thin to build on.
reqwest = { version = "0.13", default-features = false, features = ["rustls-tls", "stream", "json"] }
# `rustls` (not `rustls-tls` — renamed in 0.13) pulls in
# rustls-platform-verifier, which crashes on Android unless initialised from
# Kotlin. That is spike S3, and D7 records `tls_certs_only` + webpki-roots as
# the escape hatch.
reqwest = { version = "0.13", default-features = false, features = ["rustls", "stream", "json"] }
quick-xml = "0.41"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
url = "2.5"
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "dr-sync-nextcloud"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
dr-sync.workspace = true
reqwest.workspace = true
quick-xml.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
url.workspace = true
# Poll loop sleeps between attempts.
tokio = { workspace = true }
[dev-dependencies]
tokio.workspace = true
+187
View File
@@ -0,0 +1,187 @@
//! Login Flow v2 (FR-NC-1).
//!
//! The app never sees the user's password. It asks the server for a login URL,
//! opens that in the **system browser**, and polls until the server hands back
//! an app password scoped to this device.
use std::time::Duration;
use dr_sync::RemoteError;
use serde::Deserialize;
/// The flow's poll token is valid for 20 minutes.
const FLOW_TIMEOUT: Duration = Duration::from_secs(20 * 60);
const POLL_INTERVAL: Duration = Duration::from_secs(2);
/// What the server returns when a flow is started.
#[derive(Debug, Clone, Deserialize)]
pub struct LoginFlow {
/// Open this in the system browser — never an embedded webview, which
/// would defeat the point of not handling the password.
#[serde(rename = "login")]
pub login_url: String,
#[serde(rename = "poll")]
pub poll: PollInfo,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PollInfo {
pub token: String,
pub endpoint: String,
}
/// Credentials issued at the end of the flow.
#[derive(Debug, Clone, Deserialize)]
pub struct AppCredentials {
pub server: String,
#[serde(rename = "loginName")]
pub login_name: String,
/// Device-scoped and individually revocable — not the user's password.
#[serde(rename = "appPassword")]
pub app_password: String,
}
/// TRACES: FR-NC-1 | M-1
/// Begin a login flow.
///
/// The `User-Agent` names the resulting app password in the user's security
/// settings, so it should identify the device for per-device revocation.
pub async fn begin(
client: &reqwest::Client,
server: &str,
user_agent: &str,
) -> Result<LoginFlow, RemoteError> {
let url = format!("{}/index.php/login/v2", server.trim_end_matches('/'));
let resp = client
.post(&url)
.header(reqwest::header::USER_AGENT, user_agent)
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(RemoteError::Server {
status: resp.status().as_u16(),
detail: "login flow could not be started".into(),
});
}
resp.json::<LoginFlow>()
.await
.map_err(|e| RemoteError::Protocol(e.to_string()))
}
/// Poll until the user finishes authenticating in the browser.
///
/// The server returns 404 while pending and 200 exactly once — so a dropped
/// success response means restarting the flow, and the result must be
/// persisted immediately.
pub async fn poll(
client: &reqwest::Client,
flow: &LoginFlow,
) -> Result<AppCredentials, RemoteError> {
let deadline = std::time::Instant::now() + FLOW_TIMEOUT;
while std::time::Instant::now() < deadline {
let resp = client
.post(&flow.poll.endpoint)
// One field; encoding it by hand avoids pulling in reqwest's
// form feature for a single call.
.header(
reqwest::header::CONTENT_TYPE,
"application/x-www-form-urlencoded",
)
.body(format!("token={}", urlencode(&flow.poll.token)))
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
match resp.status().as_u16() {
200 => {
return resp
.json::<AppCredentials>()
.await
.map_err(|e| RemoteError::Protocol(e.to_string()))
}
// Still waiting for the user.
404 => tokio::time::sleep(POLL_INTERVAL).await,
s => {
return Err(RemoteError::Server {
status: s,
detail: "unexpected status while polling".into(),
})
}
}
}
Err(RemoteError::AuthFailed)
}
/// Percent-encode a form value.
fn urlencode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
/// TRACES: FR-NC-1 | M-4
/// Revoke the app password on logout (FR-NC-1).
pub async fn revoke(
client: &reqwest::Client,
server: &str,
login: &str,
password: &str,
) -> Result<(), RemoteError> {
let url = format!(
"{}/ocs/v2.php/core/apppassword",
server.trim_end_matches('/')
);
client
.delete(&url)
.basic_auth(login, Some(password))
.header("OCS-APIRequest", "true")
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_flow_response_deserialises() {
// The shape Nextcloud actually returns.
let json = r#"{
"poll": {"token": "abc", "endpoint": "https://cloud.example/login/v2/poll"},
"login": "https://cloud.example/login/v2/flow/xyz"
}"#;
let flow: LoginFlow = serde_json::from_str(json).unwrap();
assert_eq!(flow.poll.token, "abc");
assert!(flow.login_url.contains("/login/v2/flow/"));
}
#[test]
fn form_values_are_encoded() {
assert_eq!(urlencode("abc123"), "abc123");
assert_eq!(urlencode("a b+c"), "a%20b%2Bc");
}
#[test]
fn credentials_deserialise_with_camel_case_keys() {
let json = r#"{
"server": "https://cloud.example",
"loginName": "duncan",
"appPassword": "secret-token"
}"#;
let c: AppCredentials = serde_json::from_str(json).unwrap();
assert_eq!(c.login_name, "duncan");
assert_eq!(c.app_password, "secret-token");
}
}
+439
View File
@@ -0,0 +1,439 @@
//! Nextcloud connector — the only [`RemoteBackend`] implementation.
//!
//! Hand-rolled over `reqwest` rather than built on a WebDAV crate (D7). No
//! mature Nextcloud crate exists, and the operations that matter here are
//! Nextcloud extensions: `oc:fileid`, propagating directory ETags, chunked
//! upload v2, and Login Flow v2. A general WebDAV client exposes none of them.
use std::ops::Range;
use async_trait::async_trait;
use dr_sync::{
Capabilities, ChangeDetection, ChunkConstraints, Cursor, Precondition, RemoteBackend,
RemoteChange, RemoteEntry, RemoteError, RemoteId, RemotePath, ServerPreviews, Validator,
};
pub mod auth;
mod propfind;
pub use auth::{AppCredentials, LoginFlow};
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
const CHUNKS: ChunkConstraints = ChunkConstraints {
min_chunk: 5 * 1024 * 1024,
max_chunk: 5 * 1024 * 1024 * 1024,
// Below the 5 MB floor a single PUT is simpler and no slower.
single_shot_below: 5 * 1024 * 1024,
max_chunks: 10_000,
};
/// TRACES: FR-NC-12 | M-5
/// A connected Nextcloud account.
pub struct NextcloudBackend {
client: reqwest::Client,
server: String,
login: String,
password: String,
/// `/remote.php/dav/files/<user>/` — the prefix stripped from hrefs.
dav_base: String,
caps: Capabilities,
}
impl NextcloudBackend {
/// Build a backend from credentials obtained via [`auth`].
pub fn new(creds: &AppCredentials, user_id: &str) -> Result<Self, RemoteError> {
let client = reqwest::Client::builder()
.user_agent("DarkRoom")
.build()
.map_err(|e| RemoteError::Network(e.to_string()))?;
let server = creds.server.trim_end_matches('/').to_string();
let dav_base = format!("/remote.php/dav/files/{user_id}/");
Ok(Self {
client,
server,
login: creds.login_name.clone(),
password: creds.app_password.clone(),
dav_base,
caps: Capabilities {
// The property that makes a no-op sync one request (ARCH §8.1).
change_detection: ChangeDetection::PropagatingEtags,
stable_ids: true,
range_reads: true,
chunked_upload: Some(CHUNKS),
bulk_upload: true,
conditional_write: true,
// Stock Nextcloud ships no RAW preview provider (ARCH §6.7).
// Probed per-account at setup and upgraded where present.
server_previews: ServerPreviews::CommonFormatsOnly,
},
})
}
fn url_for(&self, path: &RemotePath) -> String {
let p = path.as_str();
if p.is_empty() {
format!("{}{}", self.server, self.dav_base)
} else {
format!("{}{}{}", self.server, self.dav_base, encode_path(p))
}
}
fn url_for_id(&self, id: &RemoteId) -> Result<String, RemoteError> {
match id {
RemoteId::Path(p) => Ok(self.url_for(p)),
// A stable id alone is not addressable over WebDAV; the caller
// holds the path alongside it in the catalog.
RemoteId::Stable(_) => Err(RemoteError::Unsupported(
"fetch by fileid requires a path; use RemoteId::Path",
)),
}
}
async fn propfind(
&self,
path: &RemotePath,
depth: &str,
body: &'static str,
) -> Result<String, RemoteError> {
let resp = self
.client
.request(
reqwest::Method::from_bytes(b"PROPFIND").expect("valid method"),
self.url_for(path),
)
.basic_auth(&self.login, Some(&self.password))
.header("Depth", depth)
.header(reqwest::header::CONTENT_TYPE, "application/xml")
.body(body)
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
map_status(resp.status(), path.as_str())?;
resp.text()
.await
.map_err(|e| RemoteError::Network(e.to_string()))
}
}
#[async_trait]
impl RemoteBackend for NextcloudBackend {
fn capabilities(&self) -> &Capabilities {
&self.caps
}
fn name(&self) -> &str {
"Nextcloud"
}
async fn list(
&self,
dir: &RemotePath,
_since: Option<&Validator>,
) -> Result<Vec<RemoteEntry>, RemoteError> {
// Depth 1 only. Depth: infinity is frequently disabled and
// prohibitively expensive where it is not (ARCH §8.4).
let xml = self.propfind(dir, "1", propfind::PROPFIND_BODY).await?;
propfind::parse_multistatus(&xml, &self.dav_base)
}
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError> {
// The pruning probe: Depth 0, ETag only. Against the root this is the
// single request that proves a 50k-image library unchanged.
let xml = self
.propfind(dir, "0", propfind::PROPFIND_ETAG_ONLY)
.await?;
propfind::parse_self_etag(&xml)
}
async fn delta(&self, _cursor: &Cursor) -> Result<(Vec<RemoteChange>, Cursor), RemoteError> {
// Verified absent: Nextcloud's Directory.php does not implement
// ISyncCollection, and sync tokens exist only for CalDAV/CardDAV
// (ARCH §6.6). The engine falls back to ETag pruning.
Err(RemoteError::Unsupported(
"Nextcloud has no RFC 6578 sync-collection for files",
))
}
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError> {
let url = self.url_for_id(id)?;
let mut req = self
.client
.get(&url)
.basic_auth(&self.login, Some(&self.password));
let wanted = range.clone();
if let Some(r) = &range {
req = req.header(
reqwest::header::RANGE,
format!("bytes={}-{}", r.start, r.end.saturating_sub(1)),
);
}
let resp = req
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
let status = resp.status();
map_status(status, &url)?;
let body = resp
.bytes()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?
.to_vec();
// Nextcloud does not advertise Accept-Ranges, so support is detected
// by the response code rather than by probing with HEAD (ARCH §6.7).
// A 200 to a ranged request means the server ignored it and sent
// everything; slice locally so callers still get what they asked for.
if let Some(r) = wanted {
if status.as_u16() == 200 && body.len() as u64 > r.end {
let start = r.start.min(body.len() as u64) as usize;
let end = r.end.min(body.len() as u64) as usize;
return Ok(body[start..end].to_vec());
}
}
Ok(body)
}
async fn put(
&self,
path: &RemotePath,
body: Vec<u8>,
precond: Option<Precondition>,
) -> Result<Validator, RemoteError> {
// Chunked upload is an implementation detail of put, chosen by size —
// exposing it on the trait would leak this protocol (ARCH §8.3).
if body.len() as u64 >= CHUNKS.single_shot_below {
return Err(RemoteError::Unsupported(
"chunked upload v2 not implemented yet",
));
}
let mut req = self
.client
.put(self.url_for(path))
.basic_auth(&self.login, Some(&self.password));
match &precond {
Some(Precondition::IfMatch(v)) => {
req = req.header(reqwest::header::IF_MATCH, format!("\"{}\"", v.as_str()));
}
Some(Precondition::IfAbsent) => {
req = req.header(reqwest::header::IF_NONE_MATCH, "*");
}
None => {}
}
let resp = req
.body(body)
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
map_status(resp.status(), path.as_str())?;
resp.headers()
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(Validator::new)
.ok_or_else(|| RemoteError::Protocol("no ETag on PUT response".into()))
}
async fn delete(
&self,
id: &RemoteId,
precond: Option<Precondition>,
) -> Result<(), RemoteError> {
let url = self.url_for_id(id)?;
let mut req = self
.client
.delete(&url)
.basic_auth(&self.login, Some(&self.password));
if let Some(Precondition::IfMatch(v)) = &precond {
req = req.header(reqwest::header::IF_MATCH, format!("\"{}\"", v.as_str()));
}
let resp = req
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
map_status(resp.status(), &url)
}
async fn thumbnail(&self, id: &RemoteId, size: u32) -> Result<Option<Vec<u8>>, RemoteError> {
let RemoteId::Stable(file_id) = id else {
return Ok(None);
};
// forceIcon=false is mandatory: the default returns a generic
// mimetype icon for files the server cannot render, which would
// otherwise be cached as though it were a thumbnail (ARCH §6.7).
let url = format!(
"{}/core/preview?fileId={file_id}&x={size}&y={size}&a=true&forceIcon=false",
self.server
);
let resp = self
.client
.get(&url)
.basic_auth(&self.login, Some(&self.password))
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
// 404 means no preview for this file — expected for RAW, and a
// fall-through rather than an error.
if resp.status().as_u16() == 404 {
return Ok(None);
}
map_status(resp.status(), &url)?;
Ok(Some(
resp.bytes()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?
.to_vec(),
))
}
}
/// Translate an HTTP status into a typed error.
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
match status.as_u16() {
200..=299 => Ok(()),
401 | 403 => Err(RemoteError::AuthFailed),
404 => Err(RemoteError::NotFound(what.to_string())),
// Drives the sidecar merge path rather than an overwrite (ARCH §8.5).
412 => Err(RemoteError::PreconditionFailed),
507 => Err(RemoteError::QuotaExceeded),
s => Err(RemoteError::Server {
status: s,
detail: what.to_string(),
}),
}
}
/// Percent-encode each path segment, leaving separators intact.
fn encode_path(path: &str) -> String {
path.split('/')
.map(|seg| {
seg.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
fn backend() -> NextcloudBackend {
NextcloudBackend::new(
&AppCredentials {
server: "https://cloud.example/".into(),
login_name: "duncan".into(),
app_password: "token".into(),
},
"duncan",
)
.unwrap()
}
#[test]
fn capabilities_declare_the_nextcloud_fast_path() {
let b = backend();
let c = b.capabilities();
// Propagating ETags are what make a no-op sync one request.
assert_eq!(c.change_detection, ChangeDetection::PropagatingEtags);
assert!(c.stable_ids);
assert!(c.range_reads);
assert!(c.conditional_write);
// Stock Nextcloud cannot render RAW previews.
assert_eq!(c.server_previews, ServerPreviews::CommonFormatsOnly);
}
#[test]
fn strategy_selects_etag_pruning() {
use dr_sync::SyncStrategy;
let s = SyncStrategy::for_capabilities(backend().capabilities());
assert_eq!(s, SyncStrategy::EtagPruning);
assert!(s.has_cheap_noop());
}
#[test]
fn urls_include_the_dav_prefix() {
let b = backend();
assert_eq!(
b.url_for(&RemotePath::new("Photos/2026")),
"https://cloud.example/remote.php/dav/files/duncan/Photos/2026"
);
}
#[test]
fn root_url_has_no_trailing_segment() {
let b = backend();
assert_eq!(
b.url_for(&RemotePath::root()),
"https://cloud.example/remote.php/dav/files/duncan/"
);
}
#[test]
fn path_segments_are_encoded_but_separators_are_not() {
assert_eq!(encode_path("My Trip/a b.CR2"), "My%20Trip/a%20b.CR2");
assert_eq!(encode_path("Photos/2026"), "Photos/2026");
}
#[test]
fn fetching_by_bare_fileid_is_rejected_clearly() {
// Callers hold the path alongside the id; failing loudly beats
// constructing a URL that cannot work.
let b = backend();
assert!(matches!(
b.url_for_id(&RemoteId::Stable(42)),
Err(RemoteError::Unsupported(_))
));
}
#[test]
fn statuses_map_to_actionable_errors() {
use reqwest::StatusCode;
assert!(map_status(StatusCode::OK, "x").is_ok());
assert!(matches!(
map_status(StatusCode::UNAUTHORIZED, "x"),
Err(RemoteError::AuthFailed)
));
// 412 must be distinguishable — it drives the sidecar merge.
assert!(matches!(
map_status(StatusCode::PRECONDITION_FAILED, "x"),
Err(RemoteError::PreconditionFailed)
));
assert!(matches!(
map_status(StatusCode::INSUFFICIENT_STORAGE, "x"),
Err(RemoteError::QuotaExceeded)
));
}
#[tokio::test]
async fn delta_is_unsupported_and_says_why() {
// Verified absent in the server; the engine must fall back rather
// than treat this as a failure.
let b = backend();
assert!(matches!(
b.delta(&Cursor::new("x")).await,
Err(RemoteError::Unsupported(_))
));
}
}
+453
View File
@@ -0,0 +1,453 @@
//! PROPFIND request bodies and multistatus parsing.
//!
//! Hand-rolled rather than using a WebDAV crate, because the properties that
//! matter here are Nextcloud extensions — `oc:fileid` for stable identity
//! (FR-NC-5) and `nc:has-preview` for the preview probe — and no general
//! client exposes them.
use dr_sync::{EntryKind, RemoteEntry, RemoteError, RemoteId, RemotePath, Validator};
use quick_xml::events::Event;
use quick_xml::Reader;
/// Body requesting every property the catalog stores.
///
/// One request per directory level, so asking for everything at once is
/// cheaper than a second round trip for metadata.
pub const PROPFIND_BODY: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<d:getetag/>
<d:getlastmodified/>
<d:getcontentlength/>
<d:resourcetype/>
<oc:fileid/>
<oc:size/>
<nc:has-preview/>
</d:prop>
</d:propfind>"#;
/// Body requesting only the ETag.
///
/// The cheap probe behind ETag pruning: one of these against the root answers
/// "did anything change anywhere?" (ARCH §8.1). Asking for less matters —
/// this runs on every sync.
pub const PROPFIND_ETAG_ONLY: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:getetag/></d:prop>
</d:propfind>"#;
/// One parsed `<d:response>` element.
#[derive(Debug, Default, Clone)]
struct Response {
href: String,
etag: Option<String>,
last_modified: Option<String>,
content_length: Option<u64>,
is_collection: bool,
file_id: Option<u64>,
has_preview: bool,
}
/// TRACES: FR-NC-4 | FR-NC-5 | M-5
/// Parse a multistatus body into entries.
///
/// `base` is the DAV prefix (`/remote.php/dav/files/<user>/`) stripped from
/// each href so paths are library-relative.
///
/// The entry for the requested directory itself is skipped: a `Depth: 1`
/// response includes it, and returning it would make every listing look like
/// it contains itself.
pub fn parse_multistatus(xml: &str, base: &str) -> Result<Vec<RemoteEntry>, RemoteError> {
let responses = parse_responses(xml)?;
let self_href = normalise(&percent_decode(
responses.first().map(|r| r.href.as_str()).unwrap_or(""),
));
let mut out = Vec::new();
for r in &responses {
let href = normalise(&percent_decode(&r.href));
if href == self_href {
continue;
}
let rel = href.strip_prefix(&normalise(base)).unwrap_or(&href);
let path = RemotePath::new(rel.trim_matches('/'));
// An entry with no ETag is unusable — nothing can detect its changes.
let Some(etag) = r.etag.as_ref() else {
continue;
};
out.push(RemoteEntry {
id: match r.file_id {
Some(id) => RemoteId::Stable(id),
None => RemoteId::Path(path.clone()),
},
path,
kind: if r.is_collection {
EntryKind::Directory
} else {
EntryKind::File
},
validator: Validator::new(etag),
size: r.content_length.unwrap_or(0),
modified: r.last_modified.as_deref().and_then(parse_http_date),
has_preview: r.has_preview,
});
}
Ok(out)
}
/// TRACES: FR-NC-4 | M-7
/// Extract the ETag of the *first* response — the requested resource itself.
///
/// Used by `dir_validator` for the pruning probe.
pub fn parse_self_etag(xml: &str) -> Result<Validator, RemoteError> {
let responses = parse_responses(xml)?;
responses
.first()
.and_then(|r| r.etag.as_ref())
.map(Validator::new)
.ok_or_else(|| RemoteError::Protocol("no ETag in multistatus".into()))
}
fn parse_responses(xml: &str) -> Result<Vec<Response>, RemoteError> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut out: Vec<Response> = Vec::new();
let mut cur = Response::default();
let mut in_response = false;
let mut path: Vec<String> = Vec::new();
let mut text = String::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => {
let name = local_name(e.name().as_ref());
path.push(name.clone());
match name.as_str() {
"response" => {
in_response = true;
cur = Response::default();
}
// resourcetype containing <collection/> marks a directory.
"collection" if in_response => cur.is_collection = true,
_ => {}
}
text.clear();
}
Ok(Event::Empty(e)) => {
// Self-closing <d:collection/> is the common spelling.
if local_name(e.name().as_ref()) == "collection" && in_response {
cur.is_collection = true;
}
}
Ok(Event::Text(t)) => {
// quick-xml 0.41 replaced `unescape` with `xml_content`,
// which both decodes bytes and resolves entity references.
// Nextcloud sends no XML declaration version, which is
// exactly the implicit-1.0 case.
text = t
.xml_content(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default()
.to_string();
}
Ok(Event::End(e)) => {
let name = local_name(e.name().as_ref());
if in_response {
match name.as_str() {
"href" => cur.href = text.clone(),
"getetag" => cur.etag = Some(text.clone()),
"getlastmodified" => cur.last_modified = Some(text.clone()),
"getcontentlength" => cur.content_length = text.parse().ok(),
"fileid" => cur.file_id = text.parse().ok(),
"has-preview" => cur.has_preview = text.eq_ignore_ascii_case("true"),
"response" => {
in_response = false;
out.push(std::mem::take(&mut cur));
}
_ => {}
}
}
path.pop();
text.clear();
}
Ok(Event::Eof) => {
// A truncated document reaches EOF with elements still open.
// quick-xml's streaming reader does not treat that as an
// error, so check explicitly — a half-parsed multistatus must
// not be mistaken for an empty directory.
if !path.is_empty() {
return Err(RemoteError::Protocol(format!(
"unexpected end of XML with {} element(s) unclosed",
path.len()
)));
}
break;
}
Err(e) => return Err(RemoteError::Protocol(e.to_string())),
_ => {}
}
}
Ok(out)
}
/// Strip any XML namespace prefix: `d:getetag` → `getetag`.
///
/// Servers vary in the prefixes they use, so matching on the local name is the
/// only stable approach.
fn local_name(raw: &[u8]) -> String {
let s = String::from_utf8_lossy(raw);
s.rsplit(':').next().unwrap_or(&s).to_ascii_lowercase()
}
/// Collapse duplicate slashes and ensure a single trailing slash.
fn normalise(path: &str) -> String {
let mut out = String::with_capacity(path.len() + 1);
let mut last_slash = false;
for c in path.chars() {
if c == '/' {
if !last_slash {
out.push(c);
}
last_slash = true;
} else {
out.push(c);
last_slash = false;
}
}
if !out.ends_with('/') {
out.push('/');
}
out
}
/// Decode percent-escapes in an href.
///
/// Servers escape spaces and non-ASCII, so a raw href does not match a stored
/// path without this.
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Parse an RFC 1123 date into Unix seconds.
///
/// Hand-rolled to avoid a chrono dependency for one field. Returns `None`
/// rather than guessing on an unparseable date — a wrong timestamp is worse
/// than a missing one, since it drives change detection on Tier 4 backends.
fn parse_http_date(s: &str) -> Option<i64> {
// "Wed, 09 Aug 2026 06:35:41 GMT"
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() < 5 {
return None;
}
let day: i64 = parts[1].parse().ok()?;
let month = match parts[2] {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => return None,
};
let year: i64 = parts[3].parse().ok()?;
let hms: Vec<&str> = parts[4].split(':').collect();
if hms.len() != 3 {
return None;
}
let (h, m, sec): (i64, i64, i64) = (
hms[0].parse().ok()?,
hms[1].parse().ok()?,
hms[2].parse().ok()?,
);
// Days since the Unix epoch, via a civil-from-days algorithm.
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (month + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(days * 86_400 + h * 3_600 + m * 60 + sec)
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: &str = "/remote.php/dav/files/duncan/";
/// A realistic Nextcloud Depth:1 response: the directory itself, a
/// subdirectory, and a file.
const SAMPLE: &str = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/duncan/Photos/</d:href>
<d:propstat>
<d:prop>
<d:getetag>"abc123"</d:getetag>
<d:resourcetype><d:collection/></d:resourcetype>
<oc:fileid>1001</oc:fileid>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/duncan/Photos/2026/</d:href>
<d:propstat>
<d:prop>
<d:getetag>"def456"</d:getetag>
<d:resourcetype><d:collection/></d:resourcetype>
<oc:fileid>1002</oc:fileid>
</d:prop>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/duncan/Photos/_MG_4130.CR2</d:href>
<d:propstat>
<d:prop>
<d:getetag>"ghi789"</d:getetag>
<d:getlastmodified>Wed, 09 Aug 2026 06:35:41 GMT</d:getlastmodified>
<d:getcontentlength>34489068</d:getcontentlength>
<d:resourcetype/>
<oc:fileid>1003</oc:fileid>
<nc:has-preview>false</nc:has-preview>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"#;
#[test]
fn parses_entries_and_skips_the_directory_itself() {
let entries = parse_multistatus(SAMPLE, BASE).unwrap();
// Three responses, but the first describes the requested directory.
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].path.as_str(), "Photos/2026");
assert_eq!(entries[1].path.as_str(), "Photos/_MG_4130.CR2");
}
#[test]
fn distinguishes_directories_from_files() {
let e = parse_multistatus(SAMPLE, BASE).unwrap();
assert_eq!(e[0].kind, EntryKind::Directory);
assert_eq!(e[1].kind, EntryKind::File);
}
#[test]
fn uses_fileid_as_stable_identity() {
// FR-NC-5: a server-side move must not look like delete-plus-add of a
// 34 MB file.
let e = parse_multistatus(SAMPLE, BASE).unwrap();
assert_eq!(e[1].id, RemoteId::Stable(1003));
}
#[test]
fn normalises_etag_quoting() {
// Nextcloud quotes ETags; comparing raw strings against an unquoted
// stored value would make every sync look like a change.
let e = parse_multistatus(SAMPLE, BASE).unwrap();
assert_eq!(e[1].validator.as_str(), "ghi789");
}
#[test]
fn reads_size_and_preview_flag() {
let e = parse_multistatus(SAMPLE, BASE).unwrap();
assert_eq!(e[1].size, 34_489_068);
// Stock Nextcloud reports no preview for RAW (ARCH §6.7).
assert!(!e[1].has_preview);
}
#[test]
fn parses_last_modified() {
let e = parse_multistatus(SAMPLE, BASE).unwrap();
// 2026-08-09T06:35:41Z, verified against a reference implementation
// rather than computed by hand.
assert_eq!(e[1].modified, Some(1_786_257_341));
}
#[test]
fn extracts_the_self_etag_for_pruning() {
// The one-request probe that proves a library unchanged.
assert_eq!(parse_self_etag(SAMPLE).unwrap().as_str(), "abc123");
}
#[test]
fn handles_percent_encoded_hrefs() {
let xml = r#"<d:multistatus xmlns:d="DAV:">
<d:response><d:href>/remote.php/dav/files/duncan/Photos/</d:href>
<d:propstat><d:prop><d:getetag>"a"</d:getetag>
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/duncan/Photos/My%20Trip%202026/</d:href>
<d:propstat><d:prop><d:getetag>"b"</d:getetag>
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
</d:multistatus>"#;
let e = parse_multistatus(xml, BASE).unwrap();
assert_eq!(e[0].path.as_str(), "Photos/My Trip 2026");
}
#[test]
fn tolerates_unknown_namespace_prefixes() {
// Prefixes are arbitrary; matching must be on the local name.
let xml = r#"<multistatus xmlns="DAV:">
<response><href>/remote.php/dav/files/duncan/x/</href>
<propstat><prop><getetag>"root"</getetag>
<resourcetype><collection/></resourcetype></prop></propstat></response>
<response><href>/remote.php/dav/files/duncan/x/a.jpg</href>
<propstat><prop><getetag>"one"</getetag><resourcetype/></prop></propstat></response>
</multistatus>"#;
let e = parse_multistatus(xml, BASE).unwrap();
assert_eq!(e.len(), 1);
assert_eq!(e[0].path.as_str(), "x/a.jpg");
}
#[test]
fn entries_without_an_etag_are_dropped() {
// Nothing can detect changes to them, so cataloguing one would create
// a row that never syncs correctly.
let xml = r#"<d:multistatus xmlns:d="DAV:">
<d:response><d:href>/remote.php/dav/files/duncan/x/</d:href>
<d:propstat><d:prop><d:getetag>"root"</d:getetag>
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/duncan/x/broken.jpg</d:href>
<d:propstat><d:prop><d:resourcetype/></d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response>
</d:multistatus>"#;
assert!(parse_multistatus(xml, BASE).unwrap().is_empty());
}
#[test]
fn malformed_xml_is_an_error_not_a_panic() {
assert!(parse_multistatus("<d:multistatus><unclosed>", BASE).is_err());
}
#[test]
fn http_dates_parse_or_return_none() {
assert_eq!(parse_http_date("Thu, 01 Jan 1970 00:00:00 GMT"), Some(0));
assert_eq!(parse_http_date("not a date"), None);
assert_eq!(parse_http_date(""), None);
}
}
+2 -1
View File
@@ -27,7 +27,8 @@ pub mod types;
pub use capability::{Capabilities, ChangeDetection, ChunkConstraints, ServerPreviews};
pub use error::RemoteError;
pub use types::{
Cursor, Identity, Precondition, RemoteChange, RemoteEntry, RemoteId, RemotePath, Validator,
Cursor, EntryKind, Identity, Precondition, RemoteChange, RemoteEntry, RemoteId, RemotePath,
Validator,
};
/// TRACES: FR-NC-12
+46
View File
@@ -50,8 +50,13 @@ impl SourceRef {
}
/// Lowercase file extension, if any.
///
/// Looks through a Nextcloud VFS placeholder suffix, so a dehydrated
/// `IMG.CR2.nextcloud` reports `cr2` and is catalogued as the image it
/// stands for rather than ignored as an unknown type.
pub fn extension(&self) -> Option<String> {
let name = self.display_name();
let name = name.strip_suffix(PLACEHOLDER_SUFFIX).unwrap_or(name);
let (_, ext) = name.rsplit_once('.')?;
if ext.is_empty() {
None
@@ -59,7 +64,22 @@ impl SourceRef {
Some(ext.to_ascii_lowercase())
}
}
/// Whether this names a Nextcloud VFS placeholder rather than real data.
///
/// Linux VFS is *suffix* mode: a dehydrated file exists only as
/// `NAME.EXT.nextcloud` holding one byte, and reading it does **not**
/// trigger a fetch (ARCH §9.0). Such an image is
/// [`Availability::Offline`], not corrupt — treating it as a decode
/// failure would fill a synced folder with spurious errors.
pub fn is_placeholder(&self) -> bool {
self.display_name().ends_with(PLACEHOLDER_SUFFIX)
}
}
/// TRACES: FR-NC-6c | FR-CAT-9
/// Suffix the Nextcloud desktop client appends to dehydrated files on Linux.
pub const PLACEHOLDER_SUFFIX: &str = ".nextcloud";
impl fmt::Display for SourceRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -201,6 +221,32 @@ mod tests {
assert_eq!(s.extension(), None);
}
#[test]
fn placeholders_are_recognised_not_treated_as_corrupt() {
// 121,785 of these exist in a real synced folder; each must catalogue
// as the image it stands for, marked offline.
let p = SourceRef::Local {
root: RootId(1),
relative: "Photos/_MG_4130.CR2.nextcloud".into(),
};
assert!(p.is_placeholder());
assert_eq!(p.extension().as_deref(), Some("cr2"));
assert_eq!(
p.extension().and_then(|e| Format::from_extension(&e)),
Some(Format::Cr2)
);
}
#[test]
fn a_materialised_file_is_not_a_placeholder() {
let m = SourceRef::Local {
root: RootId(1),
relative: "Photos/_MG_4130.CR2".into(),
};
assert!(!m.is_placeholder());
assert_eq!(m.extension().as_deref(), Some("cr2"));
}
#[test]
fn formats_round_trip_and_classify() {
assert_eq!(Format::from_extension("cr3"), Some(Format::Cr3));
+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
+24 -2
View File
@@ -47,6 +47,16 @@ struct Loaded {
/// preview. The remote path (FR-NC-3) fetches only a byte range, which is why
/// the decode API takes bytes rather than a reader.
fn load(path: &Path) -> Result<Loaded, String> {
// A VFS placeholder holds one byte and reading it triggers no fetch
// (ARCH §9.0). Say so plainly rather than reporting a decode failure.
if path
.file_name()
.map(|n| n.to_string_lossy().ends_with(dr_types::PLACEHOLDER_SUFFIX))
.unwrap_or(false)
{
return Err("not downloaded — Nextcloud placeholder".into());
}
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
@@ -92,9 +102,21 @@ fn collect(paths: &[PathBuf]) -> Vec<PathBuf> {
out
}
/// Whether a path names an image DarkRoom can catalogue.
///
/// Includes VFS placeholders: `IMG.CR2.nextcloud` is an image the user has,
/// just not locally (ARCH §9.0). Excluding it would make a synced folder look
/// empty rather than offline, which is the opposite of FR-NC-6c's intent.
fn is_supported(p: &Path) -> bool {
p.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
let name = p
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
let name = name
.strip_suffix(dr_types::PLACEHOLDER_SUFFIX)
.unwrap_or(&name);
name.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase())
.and_then(|e| dr_types::Format::from_extension(&e))
.is_some()
}