Open a WebDAV server as a folder in Memona
This commit is contained in:
+262
@@ -0,0 +1,262 @@
|
||||
//! WebDAV wire details: addressing, credentials, and the one XML reply shape
|
||||
//! this provider reads.
|
||||
|
||||
use crate::sdk::ErrorCode;
|
||||
|
||||
const UNRESERVED: &[u8] = b"-._~";
|
||||
const BASE64: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Percent-encodes one path segment. Slashes are excluded deliberately: the
|
||||
/// caller joins segments, so an encoded name can never grow a path boundary.
|
||||
fn segment(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.as_bytes() {
|
||||
if byte.is_ascii_alphanumeric() || UNRESERVED.contains(byte) {
|
||||
encoded.push(*byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Joins a connection-relative path onto the configured root.
|
||||
pub fn url(root: &str, path: &str) -> String {
|
||||
let mut url = root.trim_end_matches('/').to_owned();
|
||||
for part in path.split('/').filter(|part| !part.is_empty()) {
|
||||
url.push('/');
|
||||
url.push_str(&segment(part));
|
||||
}
|
||||
if path.ends_with('/') && path.len() > 1 {
|
||||
url.push('/');
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
pub fn basic(username: &str, password: &str) -> String {
|
||||
let raw = format!("{username}:{password}");
|
||||
let bytes = raw.as_bytes();
|
||||
let mut encoded = String::from("Basic ");
|
||||
for chunk in bytes.chunks(3) {
|
||||
let mut block = [0u8; 3];
|
||||
block[..chunk.len()].copy_from_slice(chunk);
|
||||
let packed = u32::from(block[0]) << 16
|
||||
| u32::from(block[1]) << 8
|
||||
| u32::from(block[2]);
|
||||
for index in 0..4 {
|
||||
if index <= chunk.len() {
|
||||
let position = (packed >> (18 - index * 6)) & 0x3f;
|
||||
encoded.push(BASE64[position as usize] as char);
|
||||
} else {
|
||||
encoded.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
/// What a WebDAV status means to a filesystem caller. A body is never shown to
|
||||
/// the user, so only the code is translated.
|
||||
pub fn outcome(status: u16) -> Result<(), ErrorCode> {
|
||||
match status {
|
||||
200..=299 => Ok(()),
|
||||
401 | 403 => Err(ErrorCode::Denied),
|
||||
404 | 410 => Err(ErrorCode::NotFound),
|
||||
405 | 409 | 412 => Err(ErrorCode::Conflict),
|
||||
// A lock is someone else holding the resource, which is a conflict to
|
||||
// resolve rather than a permission to grant.
|
||||
423 => Err(ErrorCode::Conflict),
|
||||
501 => Err(ErrorCode::Unsupported),
|
||||
_ => Err(ErrorCode::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry from a multistatus reply.
|
||||
pub struct Found {
|
||||
pub href: String,
|
||||
pub collection: bool,
|
||||
pub length: u64,
|
||||
pub etag: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads the `response` elements of a multistatus body, keeping only the four
|
||||
/// properties this provider asked for. Unknown elements and namespaces other
|
||||
/// than DAV: are skipped rather than rejected, because servers add their own.
|
||||
pub fn multistatus(body: &[u8]) -> Result<Vec<Found>, ErrorCode> {
|
||||
let mut reader = quick_xml::Reader::from_reader(body);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut found = Vec::new();
|
||||
let mut current: Option<Found> = None;
|
||||
let mut field = None;
|
||||
let mut buffer = Vec::new();
|
||||
loop {
|
||||
let event = reader
|
||||
.read_event_into(&mut buffer)
|
||||
.map_err(|_| ErrorCode::Unavailable)?;
|
||||
match event {
|
||||
quick_xml::events::Event::Eof => break,
|
||||
quick_xml::events::Event::Start(tag) => match local(tag.name().0) {
|
||||
b"response" => {
|
||||
current = Some(Found {
|
||||
href: String::new(),
|
||||
collection: false,
|
||||
length: 0,
|
||||
etag: None,
|
||||
});
|
||||
}
|
||||
b"collection" => {
|
||||
if let Some(entry) = current.as_mut() {
|
||||
entry.collection = true;
|
||||
}
|
||||
}
|
||||
name @ (b"href" | b"getcontentlength" | b"getetag") => {
|
||||
field = Some(name.to_vec());
|
||||
}
|
||||
_ => field = None,
|
||||
},
|
||||
quick_xml::events::Event::Empty(tag) => {
|
||||
if local(tag.name().0) == b"collection"
|
||||
&& let Some(entry) = current.as_mut()
|
||||
{
|
||||
entry.collection = true;
|
||||
}
|
||||
}
|
||||
quick_xml::events::Event::Text(text) => {
|
||||
let (Some(entry), Some(name)) = (current.as_mut(), field.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let value = text.unescape().map_err(|_| ErrorCode::Unavailable)?;
|
||||
match name {
|
||||
b"href" => entry.href = value.into_owned(),
|
||||
b"getcontentlength" => {
|
||||
entry.length = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
b"getetag" => entry.etag = Some(value.trim().to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
quick_xml::events::Event::End(tag) => {
|
||||
field = None;
|
||||
if local(tag.name().0) == b"response"
|
||||
&& let Some(entry) = current.take()
|
||||
{
|
||||
found.push(entry);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
buffer.clear();
|
||||
if found.len() > 4096 {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Strips any namespace prefix, so `d:href` and `D:href` read the same.
|
||||
fn local(name: &[u8]) -> &[u8] {
|
||||
match name.iter().position(|byte| *byte == b':') {
|
||||
Some(index) => &name[index + 1..],
|
||||
None => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// The last non-empty segment of an href, percent-decoded.
|
||||
pub fn name_of(href: &str) -> String {
|
||||
let trimmed = href.trim_end_matches('/');
|
||||
let tail = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
||||
let bytes = tail.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'%' && index + 2 < bytes.len() {
|
||||
let high = (bytes[index + 1] as char).to_digit(16);
|
||||
let low = (bytes[index + 2] as char).to_digit(16);
|
||||
if let (Some(high), Some(low)) = (high, low) {
|
||||
decoded.push((high * 16 + low) as u8);
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&decoded).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{basic, multistatus, name_of, outcome, url};
|
||||
use crate::sdk::ErrorCode;
|
||||
|
||||
#[test]
|
||||
fn paths_join_onto_the_root_without_growing_a_boundary() {
|
||||
assert_eq!(
|
||||
url("https://dav.example/files/", "/"),
|
||||
"https://dav.example/files"
|
||||
);
|
||||
assert_eq!(
|
||||
url("https://dav.example/files", "/notes/today.md"),
|
||||
"https://dav.example/files/notes/today.md"
|
||||
);
|
||||
// A slash inside a name is encoded, so it cannot become a new segment.
|
||||
assert_eq!(
|
||||
url("https://dav.example", "/a b/c%d"),
|
||||
"https://dav.example/a%20b/c%25d"
|
||||
);
|
||||
assert_eq!(
|
||||
url("https://dav.example", "/papers/"),
|
||||
"https://dav.example/papers/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_encode_the_way_basic_auth_expects() {
|
||||
assert_eq!(basic("user", "pass"), "Basic dXNlcjpwYXNz");
|
||||
assert_eq!(basic("a", ""), "Basic YTo=");
|
||||
assert_eq!(basic("ab", ""), "Basic YWI6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statuses_keep_denied_missing_and_conflicting_apart() {
|
||||
assert!(outcome(207).is_ok());
|
||||
assert!(matches!(outcome(401), Err(ErrorCode::Denied)));
|
||||
assert!(matches!(outcome(404), Err(ErrorCode::NotFound)));
|
||||
assert!(matches!(outcome(412), Err(ErrorCode::Conflict)));
|
||||
// A locked resource is someone else holding it, not a permission to grant.
|
||||
assert!(matches!(outcome(423), Err(ErrorCode::Conflict)));
|
||||
assert!(matches!(outcome(500), Err(ErrorCode::Unavailable)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multistatus_reply_yields_the_collection_then_its_children()
|
||||
-> Result<(), ErrorCode> {
|
||||
let body = br#"<?xml version="1.0"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>/files/notes/</D:href>
|
||||
<D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype>
|
||||
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>
|
||||
</D:response>
|
||||
<D:response>
|
||||
<D:href>/files/notes/a%20b.md</D:href>
|
||||
<D:propstat><D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getcontentlength>42</D:getcontentlength>
|
||||
<D:getetag>"v1"</D:getetag>
|
||||
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"#;
|
||||
let found = multistatus(body)?;
|
||||
assert_eq!(found.len(), 2);
|
||||
assert!(found[0].collection);
|
||||
assert!(!found[1].collection);
|
||||
assert_eq!(found[1].length, 42);
|
||||
assert_eq!(found[1].etag.as_deref(), Some("\"v1\""));
|
||||
assert_eq!(name_of(&found[0].href), "notes");
|
||||
assert_eq!(name_of(&found[1].href), "a b.md");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
//! 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::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>,
|
||||
}
|
||||
|
||||
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)) | Some(Setting::Secret(value)) => {
|
||||
let value = value.trim().to_owned();
|
||||
(!value.is_empty()).then_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. This provider addresses by path only, because
|
||||
/// WebDAV has no identity separate from the URL.
|
||||
fn route(target: &Locator) -> Result<String, ErrorCode> {
|
||||
match target {
|
||||
Locator::Path(path) => Ok(path.clone()),
|
||||
Locator::Id(_) => Err(ErrorCode::InvalidInput),
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn info_of(found: &dav::Found) -> FileInfo {
|
||||
FileInfo {
|
||||
kind: if found.collection {
|
||||
EntryKind::Directory
|
||||
} else {
|
||||
EntryKind::File
|
||||
},
|
||||
size: found.length,
|
||||
revision: found.etag.clone(),
|
||||
id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one file through a GET that started at an offset.
|
||||
struct DavReader {
|
||||
call: host::Exchange,
|
||||
revision: Option<String>,
|
||||
}
|
||||
|
||||
impl GuestReader for DavReader {
|
||||
fn pull(&self, limit: u32) -> Result<Vec<u8>, ErrorCode> {
|
||||
self.call.pull(limit.min(MAX_CHUNK)).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>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
/// 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: 0,
|
||||
revision,
|
||||
id: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Guest for Webdav {
|
||||
type Reader = DavReader;
|
||||
type Writer = DavWriter;
|
||||
|
||||
fn open(
|
||||
_provider_id: String,
|
||||
config: Vec<(String, Setting)>,
|
||||
) -> Result<Capabilities, 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 = match (lookup("username"), lookup("password")) {
|
||||
(Some(username), Some(password)) => {
|
||||
Some(dav::basic(&username, &password))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
SESSION.set(Some(Session {
|
||||
root,
|
||||
authorization,
|
||||
}));
|
||||
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,
|
||||
staged_write: false,
|
||||
publishable: false,
|
||||
virtual_directories: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn close() {
|
||||
SESSION.set(None);
|
||||
}
|
||||
|
||||
fn stat(target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||
let path = route(&target)?;
|
||||
let body = exchange(
|
||||
host::Method::Propfind,
|
||||
&path,
|
||||
vec![depth("0"), content_type()],
|
||||
PROPS.as_bytes(),
|
||||
)?;
|
||||
let found = dav::multistatus(&body)?;
|
||||
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 body = exchange(
|
||||
host::Method::Propfind,
|
||||
&path,
|
||||
vec![depth("1"), content_type()],
|
||||
PROPS.as_bytes(),
|
||||
)?;
|
||||
let found = dav::multistatus(&body)?;
|
||||
// The first response is the collection itself, which is not its own child.
|
||||
let children: Vec<_> = found
|
||||
.iter()
|
||||
.skip(1)
|
||||
.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()),
|
||||
})
|
||||
}
|
||||
|
||||
fn open_read(target: Locator, offset: u64) -> Result<Reader, ErrorCode> {
|
||||
let path = route(&target)?;
|
||||
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)?;
|
||||
dav::outcome(head.status)?;
|
||||
let revision = head
|
||||
.headers
|
||||
.iter()
|
||||
.find(|header| header.name.eq_ignore_ascii_case("etag"))
|
||||
.map(|header| header.value.clone());
|
||||
Ok(Reader::new(DavReader { call, revision }))
|
||||
}
|
||||
|
||||
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),
|
||||
}))
|
||||
}
|
||||
|
||||
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,
|
||||
id: 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))
|
||||
}
|
||||
|
||||
fn remove(target: Locator) -> Result<(), ErrorCode> {
|
||||
let path = route(&target)?;
|
||||
exchange(host::Method::Delete, &path, Vec::new(), &[])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll(_cursor: Option<String>) -> Result<ChangePage, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn publish() -> Result<Option<String>, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
sdk::export!(Webdav with_types_in sdk);
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
//! Rust guest bindings for the versioned Memona filesystem component contract,
|
||||
//! generated from the copy of the canonical WIT under `wit/`.
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "wit",
|
||||
world: "filesystem",
|
||||
pub_export_macro: true,
|
||||
});
|
||||
|
||||
pub use exports::memona::filesystem::provider::{
|
||||
Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest,
|
||||
GuestReader, GuestWriter, ListPage, Locator, Reader, WriteCondition, Writer,
|
||||
};
|
||||
pub use memona::filesystem::host;
|
||||
pub use memona::filesystem::host::Setting;
|
||||
Reference in New Issue
Block a user