501 lines
14 KiB
Rust
501 lines
14 KiB
Rust
//! A WebDAV filesystem provider for Memona, speaking only the host's HTTP.
|
|
|
|
mod dav;
|
|
mod sdk;
|
|
|
|
use sdk::host;
|
|
use sdk::{
|
|
Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest,
|
|
GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition,
|
|
Writer,
|
|
};
|
|
use std::cell::{Cell, RefCell};
|
|
|
|
/// The PROPFIND body. Asking for named properties rather than allprop keeps
|
|
/// replies small on servers that store a great many of them.
|
|
const PROPS: &str = concat!(
|
|
r#"<?xml version="1.0" encoding="utf-8"?>"#,
|
|
r#"<d:propfind xmlns:d="DAV:"><d:prop>"#,
|
|
"<d:resourcetype/><d:getcontentlength/><d:getetag/>",
|
|
"</d:prop></d:propfind>",
|
|
);
|
|
const MAX_CHUNK: u32 = 1024 * 1024;
|
|
|
|
struct Session {
|
|
root: String,
|
|
authorization: Option<String>,
|
|
}
|
|
|
|
impl Session {
|
|
fn from_config(config: &[(String, Setting)]) -> Result<Self, ErrorCode> {
|
|
let lookup = |wanted: &str| {
|
|
config
|
|
.iter()
|
|
.find(|(key, _)| key == wanted)
|
|
.and_then(|(_, value)| text(Some(value.clone())))
|
|
};
|
|
let root = lookup("url").ok_or(ErrorCode::InvalidInput)?;
|
|
if !root.starts_with("https://") && !root.starts_with("http://") {
|
|
return Err(ErrorCode::InvalidInput);
|
|
}
|
|
let authorization = lookup("username").map(|username| {
|
|
dav::basic(&username, &lookup("password").unwrap_or_default())
|
|
});
|
|
Ok(Self {
|
|
root,
|
|
authorization,
|
|
})
|
|
}
|
|
}
|
|
|
|
thread_local! {
|
|
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
|
|
}
|
|
|
|
struct Webdav;
|
|
|
|
fn text(setting: Option<Setting>) -> Option<String> {
|
|
match setting {
|
|
Some(Setting::Text(value)) => {
|
|
let value = value.trim().to_owned();
|
|
(!value.is_empty()).then_some(value)
|
|
}
|
|
Some(Setting::Secret(value)) => Some(value),
|
|
Some(Setting::Toggle(_)) | None => None,
|
|
}
|
|
}
|
|
|
|
fn transport(error: host::HostError) -> ErrorCode {
|
|
match error {
|
|
host::HostError::Denied => ErrorCode::Denied,
|
|
host::HostError::Cancelled => ErrorCode::Cancelled,
|
|
host::HostError::InvalidInput => ErrorCode::InvalidInput,
|
|
host::HostError::Internal => ErrorCode::Internal,
|
|
host::HostError::Unavailable => ErrorCode::Unavailable,
|
|
}
|
|
}
|
|
|
|
/// The path a locator names, the only way the contract addresses a resource.
|
|
fn route(target: &Locator) -> String {
|
|
let Locator::Path(path) = target;
|
|
path.clone()
|
|
}
|
|
|
|
fn session<T>(
|
|
read: impl FnOnce(&Session) -> Result<T, ErrorCode>,
|
|
) -> Result<T, ErrorCode> {
|
|
SESSION.with_borrow(|session| match session {
|
|
Some(session) => read(session),
|
|
None => Err(ErrorCode::Internal),
|
|
})
|
|
}
|
|
|
|
/// Starts one request with this connection's credentials attached.
|
|
fn begin(
|
|
method: host::Method,
|
|
path: &str,
|
|
extra: Vec<host::Header>,
|
|
) -> Result<host::Exchange, ErrorCode> {
|
|
let (url, authorization) = session(|session| {
|
|
Ok((dav::url(&session.root, path), session.authorization.clone()))
|
|
})?;
|
|
let mut headers = extra;
|
|
if let Some(value) = authorization {
|
|
headers.push(host::Header {
|
|
name: "authorization".to_owned(),
|
|
value,
|
|
});
|
|
}
|
|
host::http(&host::Request {
|
|
method,
|
|
url,
|
|
headers,
|
|
})
|
|
.map_err(transport)
|
|
}
|
|
|
|
/// Runs one request that carries a small body and answers a small body.
|
|
fn exchange(
|
|
method: host::Method,
|
|
path: &str,
|
|
extra: Vec<host::Header>,
|
|
body: &[u8],
|
|
) -> Result<Vec<u8>, ErrorCode> {
|
|
let call = begin(method, path, extra)?;
|
|
if !body.is_empty() {
|
|
call.push(body).map_err(transport)?;
|
|
}
|
|
let head = call.respond().map_err(transport)?;
|
|
dav::outcome(head.status)?;
|
|
let mut data = Vec::new();
|
|
loop {
|
|
let chunk = call.pull(MAX_CHUNK).map_err(transport)?;
|
|
if chunk.is_empty() {
|
|
return Ok(data);
|
|
}
|
|
data.extend_from_slice(&chunk);
|
|
if data.len() > 8 * 1024 * 1024 {
|
|
return Err(ErrorCode::Unavailable);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn depth(value: &str) -> host::Header {
|
|
host::Header {
|
|
name: "depth".to_owned(),
|
|
value: value.to_owned(),
|
|
}
|
|
}
|
|
|
|
fn content_type() -> host::Header {
|
|
host::Header {
|
|
name: "content-type".to_owned(),
|
|
value: "application/xml; charset=utf-8".to_owned(),
|
|
}
|
|
}
|
|
|
|
/// Asks the server about one path, and its children when `depth` is 1.
|
|
fn describe(
|
|
path: &str,
|
|
depth_value: &str,
|
|
) -> Result<Vec<dav::Found>, ErrorCode> {
|
|
let body = exchange(
|
|
host::Method::Propfind,
|
|
path,
|
|
vec![depth(depth_value), content_type()],
|
|
PROPS.as_bytes(),
|
|
)?;
|
|
dav::multistatus(&body)
|
|
}
|
|
|
|
fn info_of(found: &dav::Found) -> FileInfo {
|
|
FileInfo {
|
|
kind: if found.collection {
|
|
EntryKind::Directory
|
|
} else {
|
|
EntryKind::File
|
|
},
|
|
size: found.length,
|
|
revision: found.etag.clone(),
|
|
}
|
|
}
|
|
|
|
/// Reads one file through a GET that started at an offset.
|
|
struct DavReader {
|
|
call: host::Exchange,
|
|
revision: Option<String>,
|
|
body: RefCell<dav::Body>,
|
|
}
|
|
|
|
impl GuestReader for DavReader {
|
|
fn pull(&self, limit: u32) -> Result<Vec<u8>, ErrorCode> {
|
|
self
|
|
.body
|
|
.borrow_mut()
|
|
.pull(limit.min(MAX_CHUNK) as usize, |wanted| {
|
|
self.call.pull(wanted).map_err(transport)
|
|
})
|
|
}
|
|
fn revision(&self) -> Option<String> {
|
|
self.revision.clone()
|
|
}
|
|
}
|
|
|
|
/// Writes one file through a PUT whose body is pushed in pieces.
|
|
struct DavWriter {
|
|
call: host::Exchange,
|
|
done: RefCell<bool>,
|
|
written: Cell<u64>,
|
|
}
|
|
|
|
impl GuestWriter for DavWriter {
|
|
fn push(&self, data: Vec<u8>) -> Result<(), ErrorCode> {
|
|
if *self.done.borrow() {
|
|
return Err(ErrorCode::InvalidInput);
|
|
}
|
|
self.call.push(&data).map_err(transport)?;
|
|
self.written.set(self.written.get() + data.len() as u64);
|
|
Ok(())
|
|
}
|
|
/// One call is enough: the request body is already complete, so the response
|
|
/// head settles the write. The etag it returns, when the server sends one,
|
|
/// is the revision a later conditional write compares against.
|
|
fn finish(&self) -> Result<Option<FileInfo>, ErrorCode> {
|
|
if *self.done.borrow() {
|
|
return Err(ErrorCode::InvalidInput);
|
|
}
|
|
*self.done.borrow_mut() = true;
|
|
let head = self.call.respond().map_err(transport)?;
|
|
dav::outcome(head.status)?;
|
|
let revision = head
|
|
.headers
|
|
.iter()
|
|
.find(|header| header.name.eq_ignore_ascii_case("etag"))
|
|
.map(|header| header.value.clone());
|
|
Ok(Some(FileInfo {
|
|
kind: EntryKind::File,
|
|
size: self.written.get(),
|
|
revision,
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl Guest for Webdav {
|
|
type Reader = DavReader;
|
|
type Writer = DavWriter;
|
|
|
|
fn open(
|
|
_provider_id: String,
|
|
config: Vec<(String, Setting)>,
|
|
) -> Result<Capabilities, ErrorCode> {
|
|
SESSION.set(None);
|
|
SESSION.set(Some(Session::from_config(&config)?));
|
|
let probe = Self::stat(Locator::Path("/".to_owned())).and_then(|info| {
|
|
if matches!(info.kind, EntryKind::Directory) {
|
|
Ok(())
|
|
} else {
|
|
Err(ErrorCode::InvalidInput)
|
|
}
|
|
});
|
|
if let Err(error) = probe {
|
|
SESSION.set(None);
|
|
return Err(error);
|
|
}
|
|
Ok(Capabilities {
|
|
write: true,
|
|
mkdir: true,
|
|
rename: true,
|
|
remove: true,
|
|
// A server that returns etags supports If-Match, and one that does not
|
|
// will refuse the header rather than silently overwrite.
|
|
conditional_write: true,
|
|
watch: false,
|
|
virtual_directories: false,
|
|
})
|
|
}
|
|
|
|
fn close() {
|
|
SESSION.set(None);
|
|
}
|
|
|
|
fn stat(target: Locator) -> Result<FileInfo, ErrorCode> {
|
|
let found = describe(&route(&target), "0")?;
|
|
let entry = found.first().ok_or(ErrorCode::NotFound)?;
|
|
Ok(info_of(entry))
|
|
}
|
|
|
|
/// WebDAV has no cursor of its own, so one PROPFIND returns the whole
|
|
/// directory and the page is taken from the front of it.
|
|
fn list(
|
|
target: Locator,
|
|
cursor: Option<String>,
|
|
limit: u32,
|
|
) -> Result<ListPage, ErrorCode> {
|
|
let path = route(&target);
|
|
let start: usize = match cursor {
|
|
Some(value) => value.parse().map_err(|_| ErrorCode::InvalidInput)?,
|
|
None => 0,
|
|
};
|
|
let found = describe(&path, "1")?;
|
|
let url = session(|session| Ok(dav::url(&session.root, &path)))?;
|
|
let children: Vec<_> = dav::children(&found, &url)?
|
|
.iter()
|
|
.map(|entry| Entry {
|
|
name: dav::name_of(&entry.href),
|
|
info: info_of(entry),
|
|
})
|
|
.collect();
|
|
let end = start.saturating_add(limit as usize).min(children.len());
|
|
let entries = children.get(start..end).unwrap_or_default().to_vec();
|
|
Ok(ListPage {
|
|
entries,
|
|
cursor: (end < children.len()).then(|| end.to_string()),
|
|
})
|
|
}
|
|
|
|
/// A GET on a collection answers with whatever page the server likes to
|
|
/// show, so the path is checked to be a file before any byte is read.
|
|
fn open_read(target: Locator, offset: u64) -> Result<Reader, ErrorCode> {
|
|
let path = route(&target);
|
|
let found = describe(&path, "0")?;
|
|
let entry = found.first().ok_or(ErrorCode::NotFound)?;
|
|
if entry.collection {
|
|
return Err(ErrorCode::InvalidInput);
|
|
}
|
|
let call = begin(
|
|
host::Method::Get,
|
|
&path,
|
|
vec![host::Header {
|
|
name: "range".to_owned(),
|
|
value: format!("bytes={offset}-"),
|
|
}],
|
|
)?;
|
|
let head = call.respond().map_err(transport)?;
|
|
let body = if dav::range_outcome(head.status)? {
|
|
dav::Body::default()
|
|
} else {
|
|
dav::Body::empty()
|
|
};
|
|
// The listing's etag is the one stat and list report, and the strong form
|
|
// a later conditional write must send; the GET's may be a weak variant.
|
|
let revision = entry.etag.clone().or_else(|| {
|
|
head
|
|
.headers
|
|
.iter()
|
|
.find(|header| header.name.eq_ignore_ascii_case("etag"))
|
|
.map(|header| header.value.clone())
|
|
});
|
|
Ok(Reader::new(DavReader {
|
|
call,
|
|
revision,
|
|
body: RefCell::new(body),
|
|
}))
|
|
}
|
|
|
|
fn open_write(
|
|
target: Locator,
|
|
condition: WriteCondition,
|
|
_size_hint: Option<u64>,
|
|
) -> Result<Writer, ErrorCode> {
|
|
let path = route(&target);
|
|
let guard = match condition {
|
|
WriteCondition::CreateOnly => Some(host::Header {
|
|
name: "if-none-match".to_owned(),
|
|
value: "*".to_owned(),
|
|
}),
|
|
WriteCondition::IfMatch(revision) => Some(host::Header {
|
|
name: "if-match".to_owned(),
|
|
value: revision,
|
|
}),
|
|
WriteCondition::Overwrite => None,
|
|
};
|
|
let call = begin(host::Method::Put, &path, guard.into_iter().collect())?;
|
|
Ok(Writer::new(DavWriter {
|
|
call,
|
|
done: RefCell::new(false),
|
|
written: Cell::new(0),
|
|
}))
|
|
}
|
|
|
|
fn mkdir(target: Locator) -> Result<FileInfo, ErrorCode> {
|
|
let path = route(&target);
|
|
exchange(host::Method::Mkcol, &path, Vec::new(), &[])?;
|
|
Ok(FileInfo {
|
|
kind: EntryKind::Directory,
|
|
size: 0,
|
|
revision: None,
|
|
})
|
|
}
|
|
|
|
fn rename(
|
|
target: Locator,
|
|
destination: String,
|
|
) -> Result<FileInfo, ErrorCode> {
|
|
let path = route(&target);
|
|
let to = session(|session| Ok(dav::url(&session.root, &destination)))?;
|
|
exchange(
|
|
host::Method::Move,
|
|
&path,
|
|
vec![
|
|
host::Header {
|
|
name: "destination".to_owned(),
|
|
value: to,
|
|
},
|
|
// Never clobber an existing target: a rename that would replace one is
|
|
// a conflict for the caller to resolve.
|
|
host::Header {
|
|
name: "overwrite".to_owned(),
|
|
value: "F".to_owned(),
|
|
},
|
|
],
|
|
&[],
|
|
)?;
|
|
Self::stat(Locator::Path(destination))
|
|
}
|
|
|
|
/// DELETE takes a whole collection at once, so a directory is looked at
|
|
/// first and refused unless it is empty.
|
|
fn remove(target: Locator) -> Result<(), ErrorCode> {
|
|
let path = route(&target);
|
|
let url = session(|session| Ok(dav::url(&session.root, &path)))?;
|
|
dav::removable(&describe(&path, "1")?, &url)?;
|
|
exchange(host::Method::Delete, &path, Vec::new(), &[])?;
|
|
Ok(())
|
|
}
|
|
|
|
fn poll(_cursor: Option<String>) -> Result<ChangePage, ErrorCode> {
|
|
Err(ErrorCode::Unsupported)
|
|
}
|
|
}
|
|
|
|
sdk::export!(Webdav with_types_in sdk);
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{SESSION, Session, Webdav};
|
|
use crate::sdk::{ErrorCode, Guest, Setting};
|
|
|
|
#[tokio::test]
|
|
async fn passwords_keep_leading_and_trailing_whitespace()
|
|
-> Result<(), ErrorCode> {
|
|
let config = vec![
|
|
(
|
|
"url".to_owned(),
|
|
Setting::Text("https://dav.example/".to_owned()),
|
|
),
|
|
("username".to_owned(), Setting::Text("user".to_owned())),
|
|
(
|
|
"password".to_owned(),
|
|
Setting::Secret(" pass word \t".to_owned()),
|
|
),
|
|
];
|
|
assert_eq!(
|
|
Session::from_config(&config)?.authorization.as_deref(),
|
|
Some("Basic dXNlcjogcGFzcyB3b3JkIAk=")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_username_without_a_password_setting_sends_basic_auth()
|
|
-> Result<(), ErrorCode> {
|
|
let config = vec![
|
|
(
|
|
"url".to_owned(),
|
|
Setting::Text("https://dav.example/".to_owned()),
|
|
),
|
|
("username".to_owned(), Setting::Text("user".to_owned())),
|
|
];
|
|
assert_eq!(
|
|
Session::from_config(&config)?.authorization.as_deref(),
|
|
Some("Basic dXNlcjo=")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn no_username_keeps_the_connection_anonymous() -> Result<(), ErrorCode>
|
|
{
|
|
let config = vec![
|
|
(
|
|
"url".to_owned(),
|
|
Setting::Text("https://dav.example/".to_owned()),
|
|
),
|
|
("password".to_owned(), Setting::Secret("unused".to_owned())),
|
|
];
|
|
assert!(Session::from_config(&config)?.authorization.is_none());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_failed_reopen_discards_the_previous_connection() {
|
|
SESSION.set(Some(Session {
|
|
root: "https://dav.example/files/".to_owned(),
|
|
authorization: None,
|
|
}));
|
|
let result = Webdav::open("webdav".to_owned(), Vec::new());
|
|
assert_eq!(result.err(), Some(ErrorCode::InvalidInput));
|
|
assert!(SESSION.with_borrow(Option::is_none));
|
|
}
|
|
}
|