Files
DarkRoom/core/dr-sync-nextcloud/src/provider.rs
T
dtourolleandClaude Opus 5 ab0ef6a26d Say which requirements the code was already satisfying
Thirteen requirements were surveyed as built but untagged. Eight of them
were: R3, R6, FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and
NFR-SEC-3. Each was read against its full text in requirements.md and
against the code before the tag was added, because a tag that is wrong is
worse than an absent one — it turns a visible gap into an invisible one.

The five that were refused, and why, because the reasoning is the part
worth keeping:

R2 carries "(figure TBD)" in its own acceptance criterion and asks for a
stated prefetch margin and cache-hit rate; neither figure exists anywhere
in the tree and neither quantity is measured, while TD-2 and TD-3 both
describe the thumbnail path falling short of it.

R5 asks for three things and the code does one. The display pipeline does
run at viewport resolution, but "only visible tiles are computed" and
"panning recomputes only newly exposed tiles" need a tile scheduler that
does not exist — and frame_budget.rs currently argues for striking tiled
computation from the interactive path rather than building it.

FR-RAW-2 asks for a trait taking a SourceRef, so that a second decoder can
be added without changing callers. What exists is free functions over
&[u8]. That meets the requirement's stated *purpose* — the same decoder
serves a local file, a SAF document and a byte range, which is exactly why
it takes bytes — but there is no trait and no second implementation seam,
so the requirement should probably be amended rather than tagged.

NFR-ARCH-1 asks for named executors with stated thread counts.
architecture.md §7.1 states the table; nothing implements it. Workers are
twenty-odd ad-hoc std::thread::spawn sites, each building its own
one-worker tokio runtime, with no decode pool, no GPU-submit executor and
no I/O pool. The requirement's own text says R4 and NFR-P9 "assert an
outcome with no stated means", and that is still true.

NFR-SEC-4 is satisfied by absence — there is no telemetry — and absence
has no module to tag. A tag would point at nothing.

NFR-OPS-3 was the closest call of the eight taken. The store is single,
separate from the catalog, survives a catalog rebuild and does not sync
between devices; it has no version *field*, deliberately, and
settings.rs argues why and names the condition that would need one. The
substance is met and the reasoning is recorded where it belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:23:16 +02:00

175 lines
6.4 KiB
Rust

// TRACES: FR-NC-12 | FR-NC-1 | NFR-SEC-3
//! Registering Nextcloud as a storage backend.
//!
//! The account model this connector used to own now lives in
//! [`dr_sync::account`], where it has no server in it. What is left here is
//! the part that genuinely is Nextcloud: an endpoint is an HTTPS URL, an
//! account is established through Login Flow v2, and the credential is an app
//! password.
//!
//! Nothing above `dr_ui::remote` refers to this type.
use dr_sync::{
Account, BackendProvider, Connection, RemoteBackend, RemoteError, SignIn, LEGACY_BACKEND,
};
use crate::{AppCredentials, NextcloudBackend};
/// The id written to [`Account::backend`] for a Nextcloud account.
///
/// The same string [`dr_sync::LEGACY_BACKEND`] freezes, because every account
/// configured before there was a choice is one of these and must keep the
/// catalog directory it already has.
pub const BACKEND_ID: &str = LEGACY_BACKEND;
/// Registers the Nextcloud connector.
pub struct NextcloudProvider;
impl NextcloudProvider {
/// The account a completed login flow describes.
///
/// `user_id` is the DAV path segment, which is not always the login name:
/// a login can be an email address while the user id is something else,
/// and building `/remote.php/dav/files/<login>/` from the wrong one 404s
/// every request.
pub fn account_from(creds: &AppCredentials, user_id: impl Into<String>) -> Account {
Account::new(BACKEND_ID, creds.server.trim_end_matches('/'))
.with_login(creds.login_name.clone(), user_id)
}
/// The credentials a stored account plus its secret amount to.
///
/// [`AppCredentials`] stays the connector's own type rather than becoming
/// something general: an app password, an OAuth token and a bucket key
/// pair have no useful common shape, and inventing one would produce a
/// wrong answer confidently. The general form is [`Connection`]; this is
/// the translation into what one protocol needs.
pub fn credentials(conn: &Connection) -> Result<AppCredentials, RemoteError> {
Ok(AppCredentials {
server: conn.account.endpoint.clone(),
login_name: conn.account.login.clone(),
app_password: conn.require_secret()?.expose().to_string(),
})
}
}
impl BackendProvider for NextcloudProvider {
fn id(&self) -> &'static str {
BACKEND_ID
}
fn display_name(&self) -> &'static str {
"Nextcloud"
}
fn endpoint_label(&self) -> &'static str {
"Server"
}
fn endpoint_placeholder(&self) -> &'static str {
"https://cloud.example.com"
}
fn sign_in(&self) -> SignIn {
SignIn::Browser
}
/// Normalise a server address typed by hand.
///
/// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than
/// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS,
/// and an unencrypted default would be a security decision made on the
/// user's behalf without telling them.
fn normalise_endpoint(&self, input: &str) -> Result<String, String> {
let s = input.trim().trim_end_matches('/');
if s.is_empty() {
return Err("Enter the address of your Nextcloud server.".into());
}
if s.starts_with("https://") {
Ok(s.to_string())
} else if let Some(rest) = s.strip_prefix("http://") {
// Upgrade rather than accept. If the server genuinely has no TLS
// the connection fails loudly, which is the correct outcome.
Ok(format!("https://{rest}"))
} else {
Ok(format!("https://{s}"))
}
}
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
let creds = Self::credentials(conn)?;
Ok(Box::new(NextcloudBackend::new(
&creds,
&conn.account.user_id,
)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn creds() -> AppCredentials {
AppCredentials {
server: "https://cloud.example/".into(),
login_name: "duncan@example.com".into(),
app_password: "token".into(),
}
}
#[test]
fn an_address_typed_by_hand_becomes_an_https_url() {
let p = NextcloudProvider;
assert_eq!(
p.normalise_endpoint("cloud.example.com/").unwrap(),
"https://cloud.example.com"
);
// Upgraded, never accepted: NFR-SEC-3.
assert_eq!(
p.normalise_endpoint("http://cloud.example.com").unwrap(),
"https://cloud.example.com"
);
assert!(p.normalise_endpoint(" ").is_err());
}
#[test]
fn the_account_keeps_the_dav_user_id_apart_from_the_login() {
// A login can be an email address while the user id is something
// else; building the DAV path from the wrong one 404s everything.
let a = NextcloudProvider::account_from(&creds(), "duncan");
assert_eq!(a.login, "duncan@example.com");
assert_eq!(a.user_id, "duncan");
assert_eq!(a.endpoint, "https://cloud.example");
}
#[test]
fn a_nextcloud_account_keeps_its_historical_catalog_directory() {
// Frozen: this names the directory holding the catalog, the thumbnail
// shards and un-uploaded sidecars.
let a = NextcloudProvider::account_from(&creds(), "duncan");
assert_eq!(a.namespace(), "cloud-example-duncan");
}
#[test]
fn connecting_without_a_credential_is_unauthenticated_not_a_crash() {
// A cleared keyring or a revoked app password arrives here as an
// account with no secret. The caller re-runs the login flow.
let account = NextcloudProvider::account_from(&creds(), "duncan");
match NextcloudProvider.connect(&Connection::new(account, None)) {
Err(RemoteError::Unauthenticated) => {}
Err(e) => panic!("wrong error: {e:?}"),
Ok(b) => panic!("connected without a credential as {}", b.name()),
}
}
#[test]
fn a_stored_account_and_its_secret_rebuild_the_credentials() {
let account = NextcloudProvider::account_from(&creds(), "duncan");
let conn = Connection::new(account, Some(dr_sync::Secret::new("token")));
let rebuilt = NextcloudProvider::credentials(&conn).unwrap();
assert_eq!(rebuilt.server, "https://cloud.example");
assert_eq!(rebuilt.login_name, "duncan@example.com");
assert_eq!(rebuilt.app_password, "token");
}
}