Support requesting VFS hydration; fix Android TLS cross-compilation

Correcting the previous commit: I claimed VFS placeholders could not be
downloaded. That was wrong. The desktop client exposes a socket at
$XDG_RUNTIME_DIR/Nextcloud/socket speaking newline-delimited
COMMAND:argument, and MAKE_AVAILABLE_LOCALLY:<path> does fetch the file.
Verified against client 4.0.7: a 1-byte stub became a real 2.8MB file in
2.8 seconds.

Implemented as dr-sync-nextcloud::desktop_client, deliberately optional.
Android has no desktop client, no XDG_RUNTIME_DIR socket and no
placeholders, so detect() returns None there and callers fall back to the
connector. It earns its place only because it is ~30 lines with no
dependencies: where a library already lives in a VFS folder, asking the
client to fetch beats downloading a second copy over WebDAV and leaving
the client's placeholder state inconsistent.

What this does not change: hydration is whole-file, so it suits the
original tier and never browsing. Filling a grid this way downloads the
entire library. Range extraction remains the only mechanism satisfying
FR-NC-3, and ARCH §9.0 now says so precisely.

Also fixes two real Android build failures found by cross-compiling:

  - reqwest's `rustls` feature defaults to aws-lc-rs, whose aws-lc-sys
    crate is C and fails under the NDK — exactly the pain D1 chose Rust
    to avoid. Switched to rustls-no-provider + ring, installing the
    provider in the constructor so no caller can build a client that
    panics on first use.
  - ring itself needs CC/AR per target; cargo-ndk sets only the linker.
    Added them to the container.

87 tests passing. dr-sync-nextcloud cross-compiles for aarch64-linux-android.
This commit is contained in:
2026-08-09 10:10:29 +02:00
parent f8a718f42e
commit cc1c5c892d
9 changed files with 297 additions and 183 deletions
Generated
+6 -165
View File
@@ -452,29 +452,6 @@ dependencies = [
"arrayvec", "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]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.76" version = "0.3.76"
@@ -783,17 +760,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -836,15 +802,6 @@ dependencies = [
"hashbrown 0.16.1", "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]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.11.1" version = "0.11.1"
@@ -995,15 +952,6 @@ version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -1260,9 +1208,11 @@ dependencies = [
"async-trait", "async-trait",
"dr-sync", "dr-sync",
"dr-types", "dr-types",
"env_logger",
"log", "log",
"quick-xml", "quick-xml",
"reqwest", "reqwest",
"rustls",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.20", "thiserror 2.0.20",
@@ -1338,12 +1288,6 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]] [[package]]
name = "either" name = "either"
version = "1.17.0" version = "1.17.0"
@@ -1699,12 +1643,6 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.33" version = "0.3.33"
@@ -1854,10 +1792,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1879,11 +1815,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -3379,12 +3312,6 @@ dependencies = [
"imgref", "imgref",
] ]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lyon_algorithms" name = "lyon_algorithms"
version = "1.0.20" version = "1.0.20"
@@ -4589,63 +4516,6 @@ dependencies = [
"memchr", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@@ -4674,18 +4544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha",
"rand_core 0.9.5", "rand_core",
]
[[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]] [[package]]
@@ -4695,7 +4554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core 0.9.5", "rand_core",
] ]
[[package]] [[package]]
@@ -4707,21 +4566,6 @@ dependencies = [
"getrandom 0.3.4", "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]] [[package]]
name = "range-alloc" name = "range-alloc"
version = "0.1.5" version = "0.1.5"
@@ -4755,7 +4599,7 @@ dependencies = [
"num-traits", "num-traits",
"paste", "paste",
"profiling", "profiling",
"rand 0.9.5", "rand",
"rand_chacha", "rand_chacha",
"simd_helpers", "simd_helpers",
"thiserror 2.0.20", "thiserror 2.0.20",
@@ -4968,7 +4812,6 @@ dependencies = [
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"rustls-platform-verifier", "rustls-platform-verifier",
@@ -5127,8 +4970,8 @@ version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [ dependencies = [
"aws-lc-rs",
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@@ -5153,7 +4996,6 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -5190,7 +5032,6 @@ version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [ dependencies = [
"aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted",
+10 -5
View File
@@ -41,11 +41,16 @@ pollster = "0.4"
# Networking — no mature Nextcloud crate exists; the connector is hand-rolled # 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. # over reqwest (D7). reqwest_dav was evaluated and is too thin to build on.
# `rustls` (not `rustls-tls` — renamed in 0.13) pulls in # `rustls-no-provider` rather than `rustls`: the latter defaults to the
# rustls-platform-verifier, which crashes on Android unless initialised from # aws-lc-rs crypto provider, whose aws-lc-sys crate is C and fails to
# Kotlin. That is spike S3, and D7 records `tls_certs_only` + webpki-roots as # cross-compile for Android — precisely the NDK pain D1 chose Rust to avoid.
# the escape hatch. # ring is pure Rust apart from a small asm core that does build under the NDK.
reqwest = { version = "0.13", default-features = false, features = ["rustls", "stream", "json"] } #
# Note this still pulls rustls-platform-verifier, which crashes on Android
# unless initialised from Kotlin (spike S3). D7 records `tls_certs_only` plus
# webpki-roots as the escape hatch.
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "stream", "json"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
quick-xml = "0.41" quick-xml = "0.41"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
url = "2.5" url = "2.5"
+2
View File
@@ -9,6 +9,7 @@ license.workspace = true
dr-types.workspace = true dr-types.workspace = true
dr-sync.workspace = true dr-sync.workspace = true
reqwest.workspace = true reqwest.workspace = true
rustls.workspace = true
quick-xml.workspace = true quick-xml.workspace = true
async-trait.workspace = true async-trait.workspace = true
serde.workspace = true serde.workspace = true
@@ -21,3 +22,4 @@ tokio = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio.workspace = true tokio.workspace = true
env_logger.workspace = true
@@ -0,0 +1,50 @@
//! Request hydration of a VFS placeholder via the desktop client.
//!
//! cargo run -p dr-sync-nextcloud --example hydrate -- <file.ext.nextcloud>
use std::path::PathBuf;
use std::time::{Duration, Instant};
use dr_sync_nextcloud::desktop_client::{hydrated_path, is_placeholder, DesktopClient};
fn main() {
env_logger::init();
let Some(arg) = std::env::args().nth(1) else {
eprintln!("usage: hydrate <placeholder>");
std::process::exit(2);
};
let path = PathBuf::from(arg);
let Some(client) = DesktopClient::detect() else {
eprintln!("no desktop client running (expected on Android)");
std::process::exit(1);
};
println!("desktop client detected");
if !is_placeholder(&path) {
println!("{} is already materialised", path.display());
return;
}
let target = hydrated_path(&path);
let before = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
println!("stub: {} ({before} bytes)", path.display());
client.make_available_locally(&path).expect("send command");
println!("requested; polling for {}", target.display());
let start = Instant::now();
while start.elapsed() < Duration::from_secs(30) {
if let Ok(m) = std::fs::metadata(&target) {
println!(
"hydrated: {} bytes in {:.1}s",
m.len(),
start.elapsed().as_secs_f64()
);
return;
}
std::thread::sleep(Duration::from_millis(250));
}
println!("timed out after 30s");
std::process::exit(1);
}
@@ -0,0 +1,165 @@
//! Optional integration with a locally running Nextcloud desktop client.
//!
//! **Linux desktop only, and strictly optional.** Android has no desktop
//! client, no `XDG_RUNTIME_DIR` socket, and no VFS placeholders, so nothing
//! here exists on the platform that needs it most. Every capability offered by
//! this module is also reachable through [`crate::NextcloudBackend`], which is
//! why it is a convenience rather than a dependency (ARCH §9.0).
//!
//! What it buys where it *is* available: a user whose library already lives in
//! a VFS-synced folder can have DarkRoom ask the client to fetch a file,
//! rather than DarkRoom downloading a second copy over WebDAV and leaving the
//! client's own placeholder state inconsistent.
//!
//! The protocol is newline-delimited `COMMAND:argument` over a Unix socket.
//! Verified against client 4.0.7.
#[cfg(unix)]
use std::io::{BufRead, BufReader, Write};
#[cfg(unix)]
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use dr_sync::RemoteError;
/// How long to wait for the client to acknowledge a command.
const TIMEOUT: Duration = Duration::from_secs(5);
/// A connection to a running desktop client.
/// TRACES: FR-NC-6c | FR-CAT-9
pub struct DesktopClient {
#[cfg(unix)]
socket: PathBuf,
}
impl DesktopClient {
/// Locate a running client, if there is one.
///
/// Returns `None` on Android, where no such client exists, and on any
/// desktop where the client is not running. Callers treat `None` as
/// "use the connector", never as an error.
pub fn detect() -> Option<Self> {
#[cfg(all(unix, not(target_os = "android")))]
{
let runtime = std::env::var_os("XDG_RUNTIME_DIR")?;
let socket = PathBuf::from(runtime).join("Nextcloud/socket");
socket.exists().then_some(Self { socket })
}
#[cfg(not(all(unix, not(target_os = "android"))))]
{
None
}
}
/// Ask the client to download a placeholder.
///
/// Hydration is **whole-file**, so this is appropriate for the original
/// tier — opening an image in develop, or exporting it — and never for
/// browsing. Filling a grid this way would download the entire library,
/// which is exactly what range extraction exists to avoid (FR-NC-3).
///
/// Returns once the command is accepted, not once the download completes;
/// callers poll for the materialised path.
pub fn make_available_locally(&self, path: &Path) -> Result<(), RemoteError> {
self.send("MAKE_AVAILABLE_LOCALLY", path)
}
/// Ask the client to dehydrate a file back to a placeholder, freeing disk.
pub fn make_online_only(&self, path: &Path) -> Result<(), RemoteError> {
self.send("MAKE_ONLINE_ONLY", path)
}
#[cfg(unix)]
fn send(&self, command: &str, path: &Path) -> Result<(), RemoteError> {
let mut stream = UnixStream::connect(&self.socket)
.map_err(|e| RemoteError::Network(format!("desktop client socket: {e}")))?;
stream
.set_read_timeout(Some(TIMEOUT))
.and_then(|_| stream.set_write_timeout(Some(TIMEOUT)))
.map_err(|e| RemoteError::Network(e.to_string()))?;
writeln!(stream, "{command}:{}", path.display())
.map_err(|e| RemoteError::Network(e.to_string()))?;
stream
.flush()
.map_err(|e| RemoteError::Network(e.to_string()))?;
// The client greets with REGISTER_PATH lines; reading one confirms it
// is speaking the protocol rather than silently discarding input.
let mut line = String::new();
BufReader::new(&stream)
.read_line(&mut line)
.map_err(|e| RemoteError::Network(e.to_string()))?;
log::debug!("desktop client: {command} -> {}", line.trim());
Ok(())
}
#[cfg(not(unix))]
fn send(&self, _command: &str, _path: &Path) -> Result<(), RemoteError> {
Err(RemoteError::Unsupported(
"desktop client integration is Linux-only",
))
}
}
/// Whether a path names a VFS placeholder — a file the user has remotely but
/// not locally.
pub fn is_placeholder(path: &Path) -> bool {
path.file_name()
.map(|n| n.to_string_lossy().ends_with(dr_types::PLACEHOLDER_SUFFIX))
.unwrap_or(false)
}
/// The path a placeholder will occupy once hydrated.
///
/// Suffix-mode VFS renames on hydration, so the materialised file appears
/// under a *different* path than the stub. Callers polling for completion must
/// watch this one, not the original.
pub fn hydrated_path(placeholder: &Path) -> PathBuf {
let s = placeholder.to_string_lossy();
PathBuf::from(
s.strip_suffix(dr_types::PLACEHOLDER_SUFFIX)
.unwrap_or(&s)
.to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn placeholders_are_recognised_by_suffix() {
assert!(is_placeholder(Path::new("/x/IMG_4130.CR2.nextcloud")));
assert!(!is_placeholder(Path::new("/x/IMG_4130.CR2")));
assert!(!is_placeholder(Path::new("/x")));
}
#[test]
fn hydration_changes_the_path() {
// Suffix mode renames rather than filling in place, so polling the
// original path would wait forever.
assert_eq!(
hydrated_path(Path::new("/x/IMG_4130.CR2.nextcloud")),
PathBuf::from("/x/IMG_4130.CR2")
);
}
#[test]
fn hydrated_path_is_idempotent() {
// Calling it on an already-materialised path must not truncate it.
assert_eq!(
hydrated_path(Path::new("/x/IMG_4130.CR2")),
PathBuf::from("/x/IMG_4130.CR2")
);
}
#[test]
fn detection_returns_none_rather_than_failing() {
// Absence is the normal case — Android always, desktop whenever the
// client is not running — so it must never be an error.
let _ = DesktopClient::detect();
}
}
+22
View File
@@ -14,9 +14,11 @@ use dr_sync::{
}; };
pub mod auth; pub mod auth;
pub mod desktop_client;
mod propfind; mod propfind;
pub use auth::{AppCredentials, LoginFlow}; pub use auth::{AppCredentials, LoginFlow};
pub use desktop_client::DesktopClient;
/// Chunk sizes Nextcloud's chunked upload v2 accepts. /// Chunk sizes Nextcloud's chunked upload v2 accepts.
const CHUNKS: ChunkConstraints = ChunkConstraints { const CHUNKS: ChunkConstraints = ChunkConstraints {
@@ -42,6 +44,8 @@ pub struct NextcloudBackend {
impl NextcloudBackend { impl NextcloudBackend {
/// Build a backend from credentials obtained via [`auth`]. /// Build a backend from credentials obtained via [`auth`].
pub fn new(creds: &AppCredentials, user_id: &str) -> Result<Self, RemoteError> { pub fn new(creds: &AppCredentials, user_id: &str) -> Result<Self, RemoteError> {
install_crypto_provider();
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.user_agent("DarkRoom") .user_agent("DarkRoom")
.build() .build()
@@ -302,6 +306,24 @@ impl RemoteBackend for NextcloudBackend {
} }
} }
/// Install the rustls crypto provider, once per process.
///
/// Required because we build reqwest with `rustls-no-provider` rather than
/// `rustls`: the default provider is aws-lc-rs, whose `aws-lc-sys` crate is C
/// and does not cross-compile for Android. `ring` is pure Rust apart from a
/// small assembly core that builds fine under the NDK.
///
/// Done here rather than left to callers so there is no way to construct a
/// client that panics on first use.
fn install_crypto_provider() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
// Errs only if a provider is already installed, which is fine.
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
/// Translate an HTTP status into a typed error. /// Translate an HTTP status into a typed error.
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> { fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
match status.as_u16() { match status.as_u16() {
+13
View File
@@ -107,6 +107,19 @@ ENV CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=${NDK_BIN}/aarch64-linux-android${
ENV CARGO_NDK_PLATFORM=${MIN_API} \ ENV CARGO_NDK_PLATFORM=${MIN_API} \
ANDROID_PLATFORM=${MIN_API} ANDROID_PLATFORM=${MIN_API}
# Crates with C or assembly components (ring's crypto core, and anything else
# using the cc crate) need a compiler and archiver per target, not just a
# linker. cargo-ndk sets the linker only, so these are set explicitly —
# otherwise `ring` fails its build script and TLS cannot be built at all.
ENV CC_aarch64_linux_android=${NDK_BIN}/aarch64-linux-android${MIN_API}-clang \
AR_aarch64_linux_android=${NDK_BIN}/llvm-ar \
CC_armv7_linux_androideabi=${NDK_BIN}/armv7a-linux-androideabi${MIN_API}-clang \
AR_armv7_linux_androideabi=${NDK_BIN}/llvm-ar \
CC_x86_64_linux_android=${NDK_BIN}/x86_64-linux-android${MIN_API}-clang \
AR_x86_64_linux_android=${NDK_BIN}/llvm-ar \
CC_i686_linux_android=${NDK_BIN}/i686-linux-android${MIN_API}-clang \
AR_i686_linux_android=${NDK_BIN}/llvm-ar
# Shared cargo registry cache — bind-mount over this to persist across runs. # Shared cargo registry cache — bind-mount over this to persist across runs.
VOLUME ["/opt/cargo/registry"] VOLUME ["/opt/cargo/registry"]
+24 -8
View File
@@ -679,20 +679,36 @@ Three findings, each independently disqualifying:
`IMG.CR2.nextcloud` exists, containing exactly one byte. Any extension-based scan sees `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. `.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 2. **Reads do not hydrate, but hydration can be *requested*.** Reading a stub returns its one byte
triggered, the stub is unchanged, and the real name never appears. Suffix mode is an inert and triggers nothing — there is no FUSE layer intercepting reads. However the client exposes a
marker, not a filesystem hook — there is no FUSE layer intercepting reads. Hydration happens local socket at `$XDG_RUNTIME_DIR/Nextcloud/socket` speaking a newline-delimited
only when the *client* is instructed to sync that file. **DarkRoom cannot read through a `COMMAND:argument` protocol, and `MAKE_AVAILABLE_LOCALLY:<path>` does fetch the file.
placeholder at all.** **Verified 2026-08-09:** a 1-byte `.nextcloud` stub was replaced by the real 2.7 MB file within
seconds. `MAKE_ONLINE_ONLY` dehydrates again.
3. **Even with hydration, granularity is wrong.** VFS has two states, 1 byte or all bytes. The 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 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 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. the cost range extraction exists to avoid.
Coexistence is still fine and worth supporting: a user may keep a VFS-synced folder, and DarkRoom **What this changes, and what it does not.** Hydration-on-request makes VFS a usable *original*
should recognise `*.nextcloud` stubs and report those images as `Availability::Offline` (FR-NC-6c) tier: for an image the user opens in develop or exports, asking the client to fetch it is a
rather than as corrupt files. What it must not do is depend on VFS for transfer. legitimate alternative to fetching it ourselves, and it inherits their transfer, resume and
conflict handling for free.
It does **not** rescue the preview tier, which is the one that matters for browsing. Finding 3
stands: hydration is whole-file, so filling a grid still costs the entire library. Range extraction
remains the only mechanism that satisfies FR-NC-3.
**Design consequence.** VFS is supported as an optional *source*, not as the transfer layer:
- Recognise `*.nextcloud` stubs and report them as `Availability::Offline` (FR-NC-6c) rather than
as corrupt files.
- Where a library lives under a VFS-synced folder, offer "download" on a stub by writing
`MAKE_AVAILABLE_LOCALLY:<path>` to the socket, rather than fetching a second copy over WebDAV and
leaving the client's own state inconsistent.
- Never depend on it: the socket is Linux-only, absent on Android, and absent when the client is
not running. The direct connector remains the primary path.
### 9.1 The three tiers, restated as policy ### 9.1 The three tiers, restated as policy
+5 -5
View File
@@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value | | Metric | Value |
|---|---| |---|---|
| Source files scanned | 23 | | Source files scanned | 25 |
| TRACES tags found | 30 | | TRACES tags found | 31 |
| Requirements defined | 143 | | Requirements defined | 143 |
| Requirements covered | 32 | | Requirements covered | 32 |
| **Coverage** | **22.4%** (32/143) | | **Coverage** | **22.4%** (32/143) |
@@ -37,18 +37,18 @@ _None._
| FR-CAT-1a | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) | | 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-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-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-CAT-9 | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`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-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-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-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-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-EXP-9 | [`core/dr-decode/src/lib.rs:151`](../core/dr-decode/src/lib.rs#L151) |
| 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-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-12 | [`core/dr-sync-nextcloud/src/lib.rs:32`](../core/dr-sync-nextcloud/src/lib.rs#L32), [`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-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-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-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-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-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`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-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:90`](../core/dr-types/src/lib.rs#L90) | | 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-3 | [`core/dr-decode/src/lib.rs:151`](../core/dr-decode/src/lib.rs#L151) |