`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.
A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:
- **Capabilities** — already there, and the reason the engine can drive
two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
whatever form its connector addresses, with no server in it. Loads
every existing config unchanged (`backend` defaults to `nextcloud`,
`endpoint` is stored under its historical `server` key), and
`Account::namespace()` reproduces the old catalog directory byte for
byte, because changing it would abandon a catalog, its thumbnail
shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
`ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
names a connector.
`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.
Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.
`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.
docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
139 lines
5.2 KiB
Rust
139 lines
5.2 KiB
Rust
//! Why the server refused a write, in the server's own words.
|
|
//!
|
|
//! cargo run -p dr-sync-nextcloud --example put_probe -- <server> <remote/path>
|
|
//!
|
|
//! `map_status` turns a response into a typed error and throws the body away,
|
|
//! which is right for the application and useless for diagnosis: a 403 from
|
|
//! Sabre carries an exception class and a sentence saying *which* rule
|
|
//! refused, and that is the whole of what distinguishes a read-only share from
|
|
//! an access-control rule from a lock.
|
|
//!
|
|
//! Reads the stored session and its keyring credential, so it exercises the
|
|
//! same account the app does.
|
|
//!
|
|
//! It PROPFINDs before it writes. A path that already exists is reported and
|
|
//! left alone: overwriting a real sidecar to learn whether we may overwrite it
|
|
//! is a poor trade. Pass `--write` to test an update anyway, which is only
|
|
//! sensible against a file you are willing to lose. Absent paths are written
|
|
//! and deleted again, which tests creation and costs nothing.
|
|
|
|
use dr_plat::PlatformSecretStore;
|
|
use dr_sync::AccountStore;
|
|
use dr_sync_nextcloud::NextcloudProvider;
|
|
|
|
#[tokio::main(flavor = "current_thread")]
|
|
async fn main() {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let force = args.iter().any(|a| a == "--write");
|
|
let mut positional = args.iter().filter(|a| !a.starts_with("--"));
|
|
let (Some(server), Some(path)) = (positional.next(), positional.next()) else {
|
|
eprintln!("usage: put_probe [--write] <server-url> <remote/path>");
|
|
std::process::exit(2);
|
|
};
|
|
|
|
let sessions = AccountStore::open(Box::new(PlatformSecretStore::new()));
|
|
let Some(session) = sessions
|
|
.current()
|
|
.filter(|s| s.endpoint == server.trim_end_matches('/'))
|
|
else {
|
|
eprintln!("no stored session for {server}");
|
|
std::process::exit(1);
|
|
};
|
|
let creds = match sessions
|
|
.connection(&session, true)
|
|
.map_err(|e| e.to_string())
|
|
.and_then(|c| NextcloudProvider::credentials(&c).map_err(|e| e.to_string()))
|
|
{
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("credentials: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
let url = format!(
|
|
"{}/remote.php/dav/files/{}/{}",
|
|
session.endpoint.trim_end_matches('/'),
|
|
session.user_id,
|
|
path
|
|
);
|
|
println!("{url}");
|
|
|
|
let client = reqwest::Client::new();
|
|
let send = |method: reqwest::Method, body: Vec<u8>| {
|
|
let (url, user, pass) = (
|
|
url.clone(),
|
|
creds.login_name.clone(),
|
|
creds.app_password.clone(),
|
|
);
|
|
let client = client.clone();
|
|
async move {
|
|
client
|
|
.request(method, &url)
|
|
.basic_auth(user, Some(pass))
|
|
// Ignored by PUT and DELETE; required by PROPFIND.
|
|
.header("Depth", "0")
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
}
|
|
};
|
|
|
|
// What the server thinks of the path, before we touch it. `oc:permissions`
|
|
// on a file carries `W` when it may be updated, and the status separates
|
|
// "exists and refuses updates" from "does not exist yet".
|
|
let propfind = send(
|
|
reqwest::Method::from_bytes(b"PROPFIND").expect("valid method"),
|
|
br#"<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop><oc:permissions/></d:prop></d:propfind>"#.to_vec(),
|
|
)
|
|
.await;
|
|
let exists = match propfind {
|
|
Ok(resp) => {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
let perms = body
|
|
.split("<oc:permissions>")
|
|
.nth(1)
|
|
.and_then(|t| t.split('<').next())
|
|
.unwrap_or("(not reported)");
|
|
println!("PROPFIND -> {status}, permissions: {perms}");
|
|
if status.is_success() && !perms.contains('W') {
|
|
println!(" no W: the server will not let this account update it");
|
|
}
|
|
status.is_success()
|
|
}
|
|
Err(e) => {
|
|
println!("PROPFIND failed: {e}");
|
|
false
|
|
}
|
|
};
|
|
|
|
if exists && !force {
|
|
println!("exists already; not overwriting it. Re-run with --write to test an update.");
|
|
return;
|
|
}
|
|
|
|
println!("PUT (probe bytes)");
|
|
match send(reqwest::Method::PUT, b"probe".to_vec()).await {
|
|
Ok(resp) => {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
println!("status {status}");
|
|
// The interesting part: Sabre names the exception and the reason.
|
|
for line in body
|
|
.lines()
|
|
.filter(|l| l.contains("exception") || l.contains("message") || l.contains("Sabre"))
|
|
{
|
|
println!(" {}", line.trim());
|
|
}
|
|
// Only tidy up what we brought into being. Deleting a path that
|
|
// was already there would turn a diagnostic into data loss.
|
|
if status.is_success() && !exists {
|
|
let _ = send(reqwest::Method::DELETE, Vec::new()).await;
|
|
println!("(probe file removed)");
|
|
}
|
|
}
|
|
Err(e) => println!("request failed: {e}"),
|
|
}
|
|
}
|