diff --git a/Cargo.lock b/Cargo.lock index 17a7d0b..25bdba1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index e19bd48..9b1236b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/core/dr-sync-nextcloud/Cargo.toml b/core/dr-sync-nextcloud/Cargo.toml new file mode 100644 index 0000000..88209ee --- /dev/null +++ b/core/dr-sync-nextcloud/Cargo.toml @@ -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 diff --git a/core/dr-sync-nextcloud/src/auth.rs b/core/dr-sync-nextcloud/src/auth.rs new file mode 100644 index 0000000..4b4cbe1 --- /dev/null +++ b/core/dr-sync-nextcloud/src/auth.rs @@ -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 { + 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::() + .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 { + 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::() + .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"); + } +} diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs new file mode 100644 index 0000000..ffcefaf --- /dev/null +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -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//` — 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 { + 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 { + 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 { + 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, 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 { + // 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, 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>) -> Result, 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, + precond: Option, + ) -> Result { + // 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, + ) -> 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>, 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::() + }) + .collect::>() + .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(_)) + )); + } +} diff --git a/core/dr-sync-nextcloud/src/propfind.rs b/core/dr-sync-nextcloud/src/propfind.rs new file mode 100644 index 0000000..c4de552 --- /dev/null +++ b/core/dr-sync-nextcloud/src/propfind.rs @@ -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#" + + + + + + + + + + +"#; + +/// 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#" + + +"#; + +/// One parsed `` element. +#[derive(Debug, Default, Clone)] +struct Response { + href: String, + etag: Option, + last_modified: Option, + content_length: Option, + is_collection: bool, + file_id: Option, + 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//`) 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, 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 { + 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, RemoteError> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + + let mut out: Vec = Vec::new(); + let mut cur = Response::default(); + let mut in_response = false; + let mut path: Vec = 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 marks a directory. + "collection" if in_response => cur.is_collection = true, + _ => {} + } + text.clear(); + } + Ok(Event::Empty(e)) => { + // Self-closing 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 { + // "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#" + + + /remote.php/dav/files/duncan/Photos/ + + + "abc123" + + 1001 + + HTTP/1.1 200 OK + + + + /remote.php/dav/files/duncan/Photos/2026/ + + + "def456" + + 1002 + + + + + /remote.php/dav/files/duncan/Photos/_MG_4130.CR2 + + + "ghi789" + Wed, 09 Aug 2026 06:35:41 GMT + 34489068 + + 1003 + false + + + +"#; + + #[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#" + /remote.php/dav/files/duncan/Photos/ + "a" + + /remote.php/dav/files/duncan/Photos/My%20Trip%202026/ + "b" + +"#; + 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#" + /remote.php/dav/files/duncan/x/ + "root" + + /remote.php/dav/files/duncan/x/a.jpg + "one" +"#; + 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#" + /remote.php/dav/files/duncan/x/ + "root" + + /remote.php/dav/files/duncan/x/broken.jpg + + HTTP/1.1 404 Not Found +"#; + assert!(parse_multistatus(xml, BASE).unwrap().is_empty()); + } + + #[test] + fn malformed_xml_is_an_error_not_a_panic() { + assert!(parse_multistatus("", 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); + } +} diff --git a/core/dr-sync/src/lib.rs b/core/dr-sync/src/lib.rs index 026c6a1..f6d76c7 100644 --- a/core/dr-sync/src/lib.rs +++ b/core/dr-sync/src/lib.rs @@ -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 diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index a094313..cbc8102 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -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 { 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,8 +64,23 @@ 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 { f.write_str(self.display_name()) @@ -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)); diff --git a/docs/architecture.md b/docs/architecture.md index 6acd056..b53a1a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/traceability.md b/docs/traceability.md index 1fd1472..22e3b32 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -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.
Show untagged requirements @@ -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 diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index bfa5549..3827ad3 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -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 { + // 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 { 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() }