//! 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#""#, r#""#, "", "", ); const MAX_CHUNK: u32 = 1024 * 1024; struct Session { root: String, authorization: Option, } impl Session { fn from_config(config: &[(String, Setting)]) -> Result { 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> = const { RefCell::new(None) }; } struct Webdav; fn text(setting: Option) -> Option { 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( read: impl FnOnce(&Session) -> Result, ) -> Result { 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, ) -> Result { 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, body: &[u8], ) -> Result, 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, 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, body: RefCell, } impl GuestReader for DavReader { fn pull(&self, limit: u32) -> Result, ErrorCode> { self .body .borrow_mut() .pull(limit.min(MAX_CHUNK) as usize, |wanted| { self.call.pull(wanted).map_err(transport) }) } fn revision(&self) -> Option { self.revision.clone() } } /// Writes one file through a PUT whose body is pushed in pieces. struct DavWriter { call: host::Exchange, done: RefCell, written: Cell, } impl GuestWriter for DavWriter { fn push(&self, data: Vec) -> 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, 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 { 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 { 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, limit: u32, ) -> Result { 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 { 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, ) -> Result { 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 { 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 { 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) -> Result { 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)); } }