Open a WebDAV server as a folder under the signature plugin (#1)

Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
Danny Kim
2026-09-25 16:54:15 +00:00
committed by temeddix
parent 5d591b2fb0
commit a4d1fae1d3
12 changed files with 1563 additions and 4 deletions
+417
View File
@@ -0,0 +1,417 @@
//! 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),
}
}
/// What a ranged GET's status means to a reader: whether a body follows. A
/// range that starts at the end of the file draws 416 from Apache and most
/// other servers, which for a reader is simply end of file, not a failure.
pub fn range_outcome(status: u16) -> Result<bool, ErrorCode> {
match status {
416 => Ok(false),
_ => outcome(status).map(|()| true),
}
}
/// A response body served in pieces no larger than the caller asked for. The
/// host may hand over more than one pull wanted, so the surplus waits here for
/// the next pull rather than being dropped or passed through oversize.
#[derive(Default)]
pub struct Body {
spare: Vec<u8>,
ended: bool,
}
impl Body {
/// A body that was never sent: every pull reports end of file.
pub fn empty() -> Self {
Self {
spare: Vec::new(),
ended: true,
}
}
/// Returns at most `limit` bytes, drawing on `next` until the limit is met
/// or the body ends. `next` is asked for the shortfall and may answer with
/// more or less than that.
pub fn pull(
&mut self,
limit: usize,
mut next: impl FnMut(u32) -> Result<Vec<u8>, ErrorCode>,
) -> Result<Vec<u8>, ErrorCode> {
let mut data = std::mem::take(&mut self.spare);
while data.len() < limit && !self.ended {
let chunk = next((limit - data.len()) as u32)?;
if chunk.is_empty() {
self.ended = true;
} else {
data.extend_from_slice(&chunk);
}
}
if data.len() > limit {
self.spare = data.split_off(limit);
}
Ok(data)
}
}
/// 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()
}
/// The direct children in a Depth 1 reply. The first response describes the
/// resource asked about, so it must be a collection before the rest can be
/// called its children.
pub fn children(found: &[Found]) -> Result<&[Found], ErrorCode> {
match found.split_first() {
None => Err(ErrorCode::NotFound),
Some((first, rest)) if first.collection => Ok(rest),
Some(_) => Err(ErrorCode::InvalidInput),
}
}
/// Whether a Depth 1 reply describes something `remove` may delete: a file,
/// or a collection with no children. DELETE on a collection is recursive in
/// WebDAV, so this check is the only thing keeping a whole tree.
pub fn removable(found: &[Found]) -> Result<(), ErrorCode> {
match children(found) {
Ok([]) => Ok(()),
Ok(_) => Err(ErrorCode::Conflict),
Err(ErrorCode::InvalidInput) => Ok(()),
Err(error) => Err(error),
}
}
#[cfg(test)]
mod tests {
use super::{
Body, Found, basic, children, multistatus, name_of, outcome, range_outcome,
removable, url,
};
use crate::sdk::ErrorCode;
fn found(collection: bool) -> Found {
Found {
href: String::new(),
collection,
length: 0,
etag: None,
}
}
#[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_range_past_the_end_is_end_of_file_not_a_failure() {
assert_eq!(range_outcome(206), Ok(true));
assert_eq!(range_outcome(200), Ok(true));
assert_eq!(range_outcome(416), Ok(false));
assert_eq!(range_outcome(404), Err(ErrorCode::NotFound));
}
#[test]
fn a_body_never_hands_over_more_than_the_limit() -> Result<(), ErrorCode> {
// The host answers every pull with a whole 10-byte piece, whatever the
// shortfall asked for.
let mut asked = Vec::new();
let mut source = |wanted: u32| {
asked.push(wanted);
Ok(if asked.len() > 2 {
Vec::new()
} else {
vec![asked.len() as u8; 10]
})
};
let mut body = Body::default();
assert_eq!(body.pull(4, &mut source)?, vec![1, 1, 1, 1]);
// The surplus of the first piece is served before anything new is read.
assert_eq!(body.pull(8, &mut source)?, vec![1, 1, 1, 1, 1, 1, 2, 2]);
assert_eq!(body.pull(100, &mut source)?, vec![2; 8]);
assert_eq!(body.pull(100, &mut source)?, Vec::<u8>::new());
// Only the shortfall is asked for, and nothing after the body ended.
assert_eq!(asked, vec![4, 2, 92]);
Ok(())
}
#[test]
fn a_body_keeps_reading_until_the_limit_is_met() -> Result<(), ErrorCode> {
// A host that answers in pieces smaller than the limit is drained until
// the limit is met, so a short piece is never mistaken for end of file.
let mut body = Body::default();
let mut left = 7;
let mut source = |_wanted: u32| {
let piece = left.min(3);
left -= piece;
Ok(vec![9; piece])
};
assert_eq!(body.pull(5, &mut source)?, vec![9; 5]);
assert_eq!(body.pull(5, &mut source)?, vec![9; 2]);
assert_eq!(Body::empty().pull(5, &mut source)?, Vec::<u8>::new());
Ok(())
}
#[test]
fn only_an_empty_directory_or_a_file_may_be_removed() {
assert_eq!(removable(&[found(false)]), Ok(()));
assert_eq!(removable(&[found(true)]), Ok(()));
assert_eq!(
removable(&[found(true), found(false)]),
Err(ErrorCode::Conflict)
);
assert_eq!(removable(&[]), Err(ErrorCode::NotFound));
assert_eq!(
children(&[found(false)]).err(),
Some(ErrorCode::InvalidInput)
);
assert_eq!(
children(&[found(true), found(false), found(true)]).map(<[Found]>::len),
Ok(2)
);
}
#[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(())
}
}
+414
View File
@@ -0,0 +1,414 @@
//! A WebDAV folder for Memona that signs Markdown with an ENS name, 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>,
}
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, 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> {
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,
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 children: Vec<_> = dav::children(&found)?
.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);
dav::removable(&describe(&path, "1")?)?;
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);
+15
View File
@@ -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;