Files
DarkRoom/core/dr-sync-nextcloud/src/auth.rs
T
dtourolle f8a718f42e Add Nextcloud connector; reject VFS as a transfer mechanism
Investigated using the Nextcloud desktop client's Virtual Files as a
cache instead of talking to the server directly. Measured on this
machine (client 4.0.7): the configured folder holds 121,785 placeholders
against 10,267 materialised files, including 7,037 CR2 and 9,411 DNG.

Three findings, each independently disqualifying:

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

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

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

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

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

83 tests passing.
2026-08-09 09:09:27 +02:00

188 lines
5.6 KiB
Rust

//! Login Flow v2 (FR-NC-1).
//!
//! The app never sees the user's password. It asks the server for a login URL,
//! opens that in the **system browser**, and polls until the server hands back
//! an app password scoped to this device.
use std::time::Duration;
use dr_sync::RemoteError;
use serde::Deserialize;
/// The flow's poll token is valid for 20 minutes.
const FLOW_TIMEOUT: Duration = Duration::from_secs(20 * 60);
const POLL_INTERVAL: Duration = Duration::from_secs(2);
/// What the server returns when a flow is started.
#[derive(Debug, Clone, Deserialize)]
pub struct LoginFlow {
/// Open this in the system browser — never an embedded webview, which
/// would defeat the point of not handling the password.
#[serde(rename = "login")]
pub login_url: String,
#[serde(rename = "poll")]
pub poll: PollInfo,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PollInfo {
pub token: String,
pub endpoint: String,
}
/// Credentials issued at the end of the flow.
#[derive(Debug, Clone, Deserialize)]
pub struct AppCredentials {
pub server: String,
#[serde(rename = "loginName")]
pub login_name: String,
/// Device-scoped and individually revocable — not the user's password.
#[serde(rename = "appPassword")]
pub app_password: String,
}
/// TRACES: FR-NC-1 | M-1
/// Begin a login flow.
///
/// The `User-Agent` names the resulting app password in the user's security
/// settings, so it should identify the device for per-device revocation.
pub async fn begin(
client: &reqwest::Client,
server: &str,
user_agent: &str,
) -> Result<LoginFlow, RemoteError> {
let url = format!("{}/index.php/login/v2", server.trim_end_matches('/'));
let resp = client
.post(&url)
.header(reqwest::header::USER_AGENT, user_agent)
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(RemoteError::Server {
status: resp.status().as_u16(),
detail: "login flow could not be started".into(),
});
}
resp.json::<LoginFlow>()
.await
.map_err(|e| RemoteError::Protocol(e.to_string()))
}
/// Poll until the user finishes authenticating in the browser.
///
/// The server returns 404 while pending and 200 exactly once — so a dropped
/// success response means restarting the flow, and the result must be
/// persisted immediately.
pub async fn poll(
client: &reqwest::Client,
flow: &LoginFlow,
) -> Result<AppCredentials, RemoteError> {
let deadline = std::time::Instant::now() + FLOW_TIMEOUT;
while std::time::Instant::now() < deadline {
let resp = client
.post(&flow.poll.endpoint)
// One field; encoding it by hand avoids pulling in reqwest's
// form feature for a single call.
.header(
reqwest::header::CONTENT_TYPE,
"application/x-www-form-urlencoded",
)
.body(format!("token={}", urlencode(&flow.poll.token)))
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
match resp.status().as_u16() {
200 => {
return resp
.json::<AppCredentials>()
.await
.map_err(|e| RemoteError::Protocol(e.to_string()))
}
// Still waiting for the user.
404 => tokio::time::sleep(POLL_INTERVAL).await,
s => {
return Err(RemoteError::Server {
status: s,
detail: "unexpected status while polling".into(),
})
}
}
}
Err(RemoteError::AuthFailed)
}
/// Percent-encode a form value.
fn urlencode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
/// TRACES: FR-NC-1 | M-4
/// Revoke the app password on logout (FR-NC-1).
pub async fn revoke(
client: &reqwest::Client,
server: &str,
login: &str,
password: &str,
) -> Result<(), RemoteError> {
let url = format!(
"{}/ocs/v2.php/core/apppassword",
server.trim_end_matches('/')
);
client
.delete(&url)
.basic_auth(login, Some(password))
.header("OCS-APIRequest", "true")
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_flow_response_deserialises() {
// The shape Nextcloud actually returns.
let json = r#"{
"poll": {"token": "abc", "endpoint": "https://cloud.example/login/v2/poll"},
"login": "https://cloud.example/login/v2/flow/xyz"
}"#;
let flow: LoginFlow = serde_json::from_str(json).unwrap();
assert_eq!(flow.poll.token, "abc");
assert!(flow.login_url.contains("/login/v2/flow/"));
}
#[test]
fn form_values_are_encoded() {
assert_eq!(urlencode("abc123"), "abc123");
assert_eq!(urlencode("a b+c"), "a%20b%2Bc");
}
#[test]
fn credentials_deserialise_with_camel_case_keys() {
let json = r#"{
"server": "https://cloud.example",
"loginName": "duncan",
"appPassword": "secret-token"
}"#;
let c: AppCredentials = serde_json::from_str(json).unwrap();
assert_eq!(c.login_name, "duncan");
assert_eq!(c.app_password, "secret-token");
}
}