Trashing or emptying the trash failed on every scanned image with "operation unsupported by this backend: fetch by fileid requires a path". RemoteId::Stable(oc:fileid) is an identity — it answers whether a file is the same one after a move, and keys the thumbnail shards. It is not an address: WebDAV exposes no fileid-addressable endpoint, so the Nextcloud backend serves get/delete/move_to by path and rejects a bare Stable. Both trash workers preferred the fileid whenever the catalog knew one, so the unreachable Path fallback was the only arm that would have worked, and the failure hit every properly-scanned image rather than some edge case. Address by path at both sites, and keep the fileid for what it is for: the identity MOVE preserves, and the key the thumbnail cleanup uses. Move::file_id was documented as "the id the MOVE addresses", which is the wrong claim that seeded this; corrected, along with a note on RemoteId itself so the distinction is stated where the type is defined. Only the backend rejection was covered by a test. Added the positive case, since that contract is what the call sites now depend on. The workers build their own NextcloudBackend, so no test can reach the call sites directly — closing that would mean injecting the backend, which is left alone here. Verified by build and test; not exercised against a live server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
587 lines
20 KiB
Rust
587 lines
20 KiB
Rust
//! 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;
|
|
pub mod desktop_client;
|
|
mod propfind;
|
|
pub mod session;
|
|
|
|
pub use auth::{AppCredentials, LoginFlow};
|
|
pub use desktop_client::DesktopClient;
|
|
pub use session::{Session, SessionError, SessionStore};
|
|
|
|
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
|
|
const CHUNKS: ChunkConstraints = ChunkConstraints {
|
|
min_chunk: 5 * 1024 * 1024,
|
|
max_chunk: 5 * 1024 * 1024 * 1024,
|
|
// Below the 5 MB floor a single PUT is simpler and no slower.
|
|
single_shot_below: 5 * 1024 * 1024,
|
|
max_chunks: 10_000,
|
|
};
|
|
|
|
/// TRACES: FR-NC-12 | M-5
|
|
/// A connected Nextcloud account.
|
|
pub struct NextcloudBackend {
|
|
client: reqwest::Client,
|
|
server: String,
|
|
login: String,
|
|
password: String,
|
|
/// `/remote.php/dav/files/<user>/` — the prefix stripped from hrefs.
|
|
dav_base: String,
|
|
caps: Capabilities,
|
|
}
|
|
|
|
impl NextcloudBackend {
|
|
/// Build a backend from credentials obtained via [`auth`].
|
|
pub fn new(creds: &AppCredentials, user_id: &str) -> Result<Self, RemoteError> {
|
|
let client = http_client("DarkRoom")?;
|
|
|
|
let server = creds.server.trim_end_matches('/').to_string();
|
|
let dav_base = format!("/remote.php/dav/files/{user_id}/");
|
|
|
|
Ok(Self {
|
|
client,
|
|
server,
|
|
login: creds.login_name.clone(),
|
|
password: creds.app_password.clone(),
|
|
dav_base,
|
|
caps: Capabilities {
|
|
// The property that makes a no-op sync one request (ARCH §8.1).
|
|
change_detection: ChangeDetection::PropagatingEtags,
|
|
stable_ids: true,
|
|
range_reads: true,
|
|
chunked_upload: Some(CHUNKS),
|
|
bulk_upload: true,
|
|
conditional_write: true,
|
|
// Stock Nextcloud ships no RAW preview provider (ARCH §6.7).
|
|
// Probed per-account at setup and upgraded where present.
|
|
server_previews: ServerPreviews::CommonFormatsOnly,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn url_for(&self, path: &RemotePath) -> String {
|
|
let p = path.as_str();
|
|
if p.is_empty() {
|
|
format!("{}{}", self.server, self.dav_base)
|
|
} else {
|
|
format!("{}{}{}", self.server, self.dav_base, encode_path(p))
|
|
}
|
|
}
|
|
|
|
fn url_for_id(&self, id: &RemoteId) -> Result<String, RemoteError> {
|
|
match id {
|
|
RemoteId::Path(p) => Ok(self.url_for(p)),
|
|
// A stable id alone is not addressable over WebDAV; the caller
|
|
// holds the path alongside it in the catalog.
|
|
RemoteId::Stable(_) => Err(RemoteError::Unsupported(
|
|
"fetch by fileid requires a path; use RemoteId::Path",
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn propfind(
|
|
&self,
|
|
path: &RemotePath,
|
|
depth: &str,
|
|
body: &'static str,
|
|
) -> Result<String, RemoteError> {
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"PROPFIND").expect("valid method"),
|
|
self.url_for(path),
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.header("Depth", depth)
|
|
.header(reqwest::header::CONTENT_TYPE, "application/xml")
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
|
|
map_status(resp.status(), path.as_str())?;
|
|
resp.text()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RemoteBackend for NextcloudBackend {
|
|
fn capabilities(&self) -> &Capabilities {
|
|
&self.caps
|
|
}
|
|
|
|
fn name(&self) -> &str {
|
|
"Nextcloud"
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
dir: &RemotePath,
|
|
_since: Option<&Validator>,
|
|
) -> Result<Vec<RemoteEntry>, RemoteError> {
|
|
// Depth 1 only. Depth: infinity is frequently disabled and
|
|
// prohibitively expensive where it is not (ARCH §8.4).
|
|
let xml = self.propfind(dir, "1", propfind::PROPFIND_BODY).await?;
|
|
propfind::parse_multistatus(&xml, &self.dav_base)
|
|
}
|
|
|
|
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError> {
|
|
// The pruning probe: Depth 0, ETag only. Against the root this is the
|
|
// single request that proves a 50k-image library unchanged.
|
|
let xml = self
|
|
.propfind(dir, "0", propfind::PROPFIND_ETAG_ONLY)
|
|
.await?;
|
|
propfind::parse_self_etag(&xml)
|
|
}
|
|
|
|
async fn delta(&self, _cursor: &Cursor) -> Result<(Vec<RemoteChange>, Cursor), RemoteError> {
|
|
// Verified absent: Nextcloud's Directory.php does not implement
|
|
// ISyncCollection, and sync tokens exist only for CalDAV/CardDAV
|
|
// (ARCH §6.6). The engine falls back to ETag pruning.
|
|
Err(RemoteError::Unsupported(
|
|
"Nextcloud has no RFC 6578 sync-collection for files",
|
|
))
|
|
}
|
|
|
|
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError> {
|
|
let url = self.url_for_id(id)?;
|
|
let mut req = self
|
|
.client
|
|
.get(&url)
|
|
.basic_auth(&self.login, Some(&self.password));
|
|
|
|
let wanted = range.clone();
|
|
if let Some(r) = &range {
|
|
req = req.header(
|
|
reqwest::header::RANGE,
|
|
format!("bytes={}-{}", r.start, r.end.saturating_sub(1)),
|
|
);
|
|
}
|
|
|
|
let resp = req
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
let status = resp.status();
|
|
map_status(status, &url)?;
|
|
|
|
let body = resp
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?
|
|
.to_vec();
|
|
|
|
// Nextcloud does not advertise Accept-Ranges, so support is detected
|
|
// by the response code rather than by probing with HEAD (ARCH §6.7).
|
|
// A 200 to a ranged request means the server ignored it and sent
|
|
// everything; slice locally so callers still get what they asked for.
|
|
if let Some(r) = wanted {
|
|
if status.as_u16() == 200 && body.len() as u64 > r.end {
|
|
let start = r.start.min(body.len() as u64) as usize;
|
|
let end = r.end.min(body.len() as u64) as usize;
|
|
return Ok(body[start..end].to_vec());
|
|
}
|
|
}
|
|
|
|
Ok(body)
|
|
}
|
|
|
|
async fn put(
|
|
&self,
|
|
path: &RemotePath,
|
|
body: Vec<u8>,
|
|
precond: Option<Precondition>,
|
|
) -> Result<Validator, RemoteError> {
|
|
// Chunked upload is an implementation detail of put, chosen by size —
|
|
// exposing it on the trait would leak this protocol (ARCH §8.3).
|
|
if body.len() as u64 >= CHUNKS.single_shot_below {
|
|
return Err(RemoteError::Unsupported(
|
|
"chunked upload v2 not implemented yet",
|
|
));
|
|
}
|
|
|
|
let mut req = self
|
|
.client
|
|
.put(self.url_for(path))
|
|
.basic_auth(&self.login, Some(&self.password));
|
|
|
|
match &precond {
|
|
Some(Precondition::IfMatch(v)) => {
|
|
req = req.header(reqwest::header::IF_MATCH, format!("\"{}\"", v.as_str()));
|
|
}
|
|
Some(Precondition::IfAbsent) => {
|
|
req = req.header(reqwest::header::IF_NONE_MATCH, "*");
|
|
}
|
|
None => {}
|
|
}
|
|
|
|
let resp = req
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
map_status(resp.status(), path.as_str())?;
|
|
|
|
resp.headers()
|
|
.get(reqwest::header::ETAG)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(Validator::new)
|
|
.ok_or_else(|| RemoteError::Protocol("no ETag on PUT response".into()))
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
id: &RemoteId,
|
|
precond: Option<Precondition>,
|
|
) -> Result<(), RemoteError> {
|
|
let url = self.url_for_id(id)?;
|
|
let mut req = self
|
|
.client
|
|
.delete(&url)
|
|
.basic_auth(&self.login, Some(&self.password));
|
|
|
|
if let Some(Precondition::IfMatch(v)) = &precond {
|
|
req = req.header(reqwest::header::IF_MATCH, format!("\"{}\"", v.as_str()));
|
|
}
|
|
|
|
let resp = req
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
map_status(resp.status(), &url)
|
|
}
|
|
|
|
/// TRACES: FR-CAT-15
|
|
/// WebDAV `MOVE`, which preserves `oc:fileid`.
|
|
///
|
|
/// That preservation is the whole reason this is a `MOVE` and not a
|
|
/// `GET`+`PUT`+`DELETE`: the file id is what the thumbnail store keys on and
|
|
/// what the sidecar mapping records, so a move that allocated a new one
|
|
/// would orphan both and turn a trash-then-restore into a full re-download
|
|
/// of every affected file.
|
|
///
|
|
/// `Overwrite: F` — a move must never destroy something already at the
|
|
/// destination. The trash path carries the image id precisely so this cannot
|
|
/// normally fire, but a header that permits overwriting is a header that
|
|
/// eventually does.
|
|
async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError> {
|
|
let src = self.url_for_id(from)?;
|
|
let dest = self.url_for(to);
|
|
|
|
// The parent has to exist; MOVE does not create it. Nothing else
|
|
// creates the trash folder, so the first trashed image would otherwise
|
|
// fail with a 409 that reads like a permission problem.
|
|
if let Some(parent) = to.parent() {
|
|
self.create_dir(&parent).await?;
|
|
}
|
|
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"MOVE").expect("valid method"),
|
|
&src,
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.header("Destination", &dest)
|
|
.header("Overwrite", "F")
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
|
|
map_status(resp.status(), &src)
|
|
}
|
|
|
|
/// `MKCOL`, treating "already there" as success.
|
|
///
|
|
/// Callers use this to guarantee a destination exists, not to claim they
|
|
/// created it — so `405 Method Not Allowed`, which is what Nextcloud returns
|
|
/// for an existing collection, is the goal state and not an error.
|
|
///
|
|
/// Parents are created outermost-first: `MKCOL` fails with `409` if the
|
|
/// parent is missing, and the trash folder's parent is the library root,
|
|
/// which may itself be several levels down.
|
|
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError> {
|
|
// Build the chain of ancestors, shallowest first.
|
|
let mut chain = Vec::new();
|
|
let mut current = Some(path.clone());
|
|
while let Some(p) = current {
|
|
if p.as_str().is_empty() {
|
|
break;
|
|
}
|
|
current = p.parent();
|
|
chain.push(p);
|
|
}
|
|
chain.reverse();
|
|
|
|
for dir in chain {
|
|
let url = self.url_for(&dir);
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"MKCOL").expect("valid method"),
|
|
&url,
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
|
|
// 405 is "already a collection here", which is exactly what the
|
|
// caller wanted. Anything else is reported.
|
|
if resp.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED {
|
|
continue;
|
|
}
|
|
map_status(resp.status(), &url)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn thumbnail(&self, id: &RemoteId, size: u32) -> Result<Option<Vec<u8>>, RemoteError> {
|
|
let RemoteId::Stable(file_id) = id else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// forceIcon=false is mandatory: the default returns a generic
|
|
// mimetype icon for files the server cannot render, which would
|
|
// otherwise be cached as though it were a thumbnail (ARCH §6.7).
|
|
let url = format!(
|
|
"{}/core/preview?fileId={file_id}&x={size}&y={size}&a=true&forceIcon=false",
|
|
self.server
|
|
);
|
|
|
|
let resp = self
|
|
.client
|
|
.get(&url)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.send()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
|
|
|
// 404 means no preview for this file — expected for RAW, and a
|
|
// fall-through rather than an error.
|
|
if resp.status().as_u16() == 404 {
|
|
return Ok(None);
|
|
}
|
|
map_status(resp.status(), &url)?;
|
|
|
|
Ok(Some(
|
|
resp.bytes()
|
|
.await
|
|
.map_err(|e| RemoteError::Network(e.to_string()))?
|
|
.to_vec(),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Build an HTTP client with the crypto provider already installed.
|
|
///
|
|
/// Every entry point that creates a client must go through here. The auth
|
|
/// flow runs *before* a backend exists, so leaving the install to
|
|
/// `NextcloudBackend::new` means `auth::begin` panics on first use — which is
|
|
/// exactly what happened.
|
|
pub fn http_client(user_agent: &str) -> Result<reqwest::Client, RemoteError> {
|
|
install_crypto_provider();
|
|
reqwest::Client::builder()
|
|
.user_agent(user_agent.to_string())
|
|
.build()
|
|
.map_err(|e| RemoteError::Network(e.to_string()))
|
|
}
|
|
|
|
/// 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.
|
|
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
|
|
match status.as_u16() {
|
|
200..=299 => Ok(()),
|
|
401 => Err(RemoteError::AuthFailed),
|
|
// Not an auth failure: the credential authenticated fine and reads
|
|
// work. Reporting this as "authentication rejected" sends the user to
|
|
// re-check a working login (observed 2026-08-09: PROPFIND 207, PUT
|
|
// 403 `Sabre\DAV\Exception\Forbidden`, same app password).
|
|
403 => Err(RemoteError::PermissionDenied),
|
|
404 => Err(RemoteError::NotFound(what.to_string())),
|
|
// Drives the sidecar merge path rather than an overwrite (ARCH §8.5).
|
|
412 => Err(RemoteError::PreconditionFailed),
|
|
507 => Err(RemoteError::QuotaExceeded),
|
|
s => Err(RemoteError::Server {
|
|
status: s,
|
|
detail: what.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Percent-encode each path segment, leaving separators intact.
|
|
fn encode_path(path: &str) -> String {
|
|
path.split('/')
|
|
.map(|seg| {
|
|
seg.bytes()
|
|
.map(|b| match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
(b as char).to_string()
|
|
}
|
|
_ => format!("%{b:02X}"),
|
|
})
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("/")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn backend() -> NextcloudBackend {
|
|
NextcloudBackend::new(
|
|
&AppCredentials {
|
|
server: "https://cloud.example/".into(),
|
|
login_name: "duncan".into(),
|
|
app_password: "token".into(),
|
|
},
|
|
"duncan",
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn capabilities_declare_the_nextcloud_fast_path() {
|
|
let b = backend();
|
|
let c = b.capabilities();
|
|
// Propagating ETags are what make a no-op sync one request.
|
|
assert_eq!(c.change_detection, ChangeDetection::PropagatingEtags);
|
|
assert!(c.stable_ids);
|
|
assert!(c.range_reads);
|
|
assert!(c.conditional_write);
|
|
// Stock Nextcloud cannot render RAW previews.
|
|
assert_eq!(c.server_previews, ServerPreviews::CommonFormatsOnly);
|
|
}
|
|
|
|
#[test]
|
|
fn strategy_selects_etag_pruning() {
|
|
use dr_sync::SyncStrategy;
|
|
let s = SyncStrategy::for_capabilities(backend().capabilities());
|
|
assert_eq!(s, SyncStrategy::EtagPruning);
|
|
assert!(s.has_cheap_noop());
|
|
}
|
|
|
|
#[test]
|
|
fn urls_include_the_dav_prefix() {
|
|
let b = backend();
|
|
assert_eq!(
|
|
b.url_for(&RemotePath::new("Photos/2026")),
|
|
"https://cloud.example/remote.php/dav/files/duncan/Photos/2026"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn root_url_has_no_trailing_segment() {
|
|
let b = backend();
|
|
assert_eq!(
|
|
b.url_for(&RemotePath::root()),
|
|
"https://cloud.example/remote.php/dav/files/duncan/"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn path_segments_are_encoded_but_separators_are_not() {
|
|
assert_eq!(encode_path("My Trip/a b.CR2"), "My%20Trip/a%20b.CR2");
|
|
assert_eq!(encode_path("Photos/2026"), "Photos/2026");
|
|
}
|
|
|
|
#[test]
|
|
fn fetching_by_bare_fileid_is_rejected_clearly() {
|
|
// Callers hold the path alongside the id; failing loudly beats
|
|
// constructing a URL that cannot work.
|
|
let b = backend();
|
|
assert!(matches!(
|
|
b.url_for_id(&RemoteId::Stable(42)),
|
|
Err(RemoteError::Unsupported(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn addressing_by_path_is_what_works() {
|
|
// The counterpart to the rejection above, and the reason trash and
|
|
// purge pass `RemoteId::Path` even when the catalog knows the fileid:
|
|
// every id-taking method routes through here, so a `Stable` turns a
|
|
// trash into "operation unsupported by this backend".
|
|
let b = backend();
|
|
assert_eq!(
|
|
b.url_for_id(&RemoteId::Path(RemotePath::new("Photos/_MG_8154.dng")))
|
|
.unwrap(),
|
|
"https://cloud.example/remote.php/dav/files/duncan/Photos/_MG_8154.dng"
|
|
);
|
|
}
|
|
|
|
#[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)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn http_client_works_before_any_backend_exists() {
|
|
// The auth flow builds a client before a backend is constructed. If
|
|
// the crypto provider is installed only in NextcloudBackend::new,
|
|
// this panics — which is exactly what happened against the live
|
|
// server.
|
|
let c = http_client("test");
|
|
assert!(c.is_ok(), "client must build without a backend");
|
|
}
|
|
|
|
#[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(_))
|
|
));
|
|
}
|
|
}
|