Open a WebDAV server as a folder in Memona

This commit is contained in:
Danny Kim
2026-09-25 11:57:03 +09:00
commit bca12b4c2e
12 changed files with 1365 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
warn-on-all-wildcard-imports = true
+1
View File
@@ -0,0 +1 @@
/target/
+3
View File
@@ -0,0 +1,3 @@
style_edition = "2024"
max_width = 80
tab_spaces = 2
Generated
+336
View File
@@ -0,0 +1,336 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memona-plugin-webdav"
version = "0.1.0"
dependencies = [
"quick-xml",
"wit-bindgen",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "wasm-encoder"
version = "0.259.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d0246511d901aacf25d2dc9111f0054947de5e093fe662099e709ff7530dc3"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.259.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7bf119eb01f4a246864c10bd87c8f26c566350abb25aa4d5273c415bbab338"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.259.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f7c12eac7bb587801590f6a67ff0bc84d0748513864b71108e4b311cc3df694"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53cb4b5556c3a791e86838ea287782bdafa704d55b0e68b5b81a3a16b9ea5f4b"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ad22a37ecbc0e1fdca34d2b670ba41368cb0920735643e00fdf39a16ee5431"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84ba5213d4332c260a0d4e69e34b0f7e4b85ddae10b6ebfbac00d9c185ba628a"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f19d63da8e478a370ef8192d90ccb517965b7747d6b46cd2d831dd0ef4d2541"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.259.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092f783dee3253265fcde131475055f8c275ac0176a9c0396db1dc7b5544bfb4"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.259.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6390f4f02493ce678d509c859cf1698e38929fb75c8012a6ee2f7d6b6cb66cd7"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "memona-plugin-webdav"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "A WebDAV filesystem provider for Memona, built as a WASI component."
repository = "https://git.capsulizers.com/commons/memona-plugin-webdav"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wit-bindgen = "=0.62.0"
quick-xml = { version = "0.37", default-features = false }
[lints.clippy]
panic = "deny"
unwrap_used = "deny"
expect_used = "deny"
wildcard_imports = "deny"
[profile.dev]
debug = 0
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Memona contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+54
View File
@@ -0,0 +1,54 @@
# Memona WebDAV plugin
Opens a WebDAV server as a folder inside Memona, so agents and the file
manager work in it like any local directory. The plugin is a WASI component: it
speaks no sockets of its own and makes every request through the host's HTTP
interface, under the connection's permissions.
It registers one provider, `webdav`, for the `webdav` and `davs` schemes.
## Connecting
| Field | Meaning |
| --- | --- |
| Server address | The folder's address, for example `https://dav.example.com/files/` |
| Username | Optional account name for HTTP basic authentication |
| Password | Optional password, stored as a secret |
Listing, reading, writing, creating directories, renaming and deleting are
supported. Reads and writes stream in pieces of at most 1 MiB, so a file of any
size moves without either side holding it whole. Conditional writes use
`If-Match` and `If-None-Match`, which servers that return etags honour. Change
polling and publishing are not supported, and a rename never replaces an
existing target.
## Building
```sh
rustup target add wasm32-wasip2
cargo build --locked --release --target wasm32-wasip2
```
The component lands at
`target/wasm32-wasip2/release/memona_plugin_webdav.wasm`.
`wit/filesystem.wit` is a copy of the canonical Memona filesystem contract, and
`src/sdk.rs` generates the guest bindings from it, so this repository builds
without a Memona checkout. Refresh the copy from `plugin_system/sdk/wit` in the
[Memona repository](https://git.capsulizers.com/lead/memona) when the contract
moves, and keep `apiVersion` in `manifest.json` in step.
## Packaging for the plugin store
A `.memona-plugin` package is a ZIP archive with the files at its root, never
inside a directory:
```sh
cargo build --locked --release --target wasm32-wasip2
cp target/wasm32-wasip2/release/memona_plugin_webdav.wasm plugin.wasm
zip memona-plugin-webdav.memona-plugin \
manifest.json plugin.wasm README.md LICENSE
```
`manifest.json` and `plugin.wasm` are required; `README.md` becomes the guide on
the plugin's public page and `LICENSE` is shown beside it.
+40
View File
@@ -0,0 +1,40 @@
{
"schemaVersion": 1,
"id": "memona/webdav",
"name": "WebDAV",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Open a WebDAV server as a folder, and let agents work in it.",
"license": "MIT",
"entry": "plugin.wasm",
"networkHosts": ["*"],
"networkPorts": [],
"providers": [
{
"id": "webdav",
"name": "WebDAV",
"schemes": ["webdav", "davs"],
"configFields": [
{
"key": "url",
"label": "Server address",
"description": "The folder's address, for example https://dav.example.com/files/",
"kind": "text",
"required": true
},
{
"key": "username",
"label": "Username",
"kind": "text",
"required": false
},
{
"key": "password",
"label": "Password",
"kind": "password",
"required": false
}
]
}
]
}
+262
View File
@@ -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
View File
@@ -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
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;
+215
View File
@@ -0,0 +1,215 @@
package memona:filesystem@0.1.0;
/// Host services scoped to the current connection. No ambient filesystem,
/// environment, sockets, credentials, or network permissions are inherited.
interface host {
/// Stable failures without backend messages or credential-bearing URLs.
enum host-error { denied, unavailable, invalid-input, cancelled, internal }
/// Severity of a diagnostic event. The host may discard message contents.
enum log-level { debug, info, warn, error }
/// Every method the host will send. A verb outside this list cannot be
/// spelled, so no plugin can smuggle one past the transport rules.
enum method {
get, head, post, put, patch, delete,
propfind, proppatch, mkcol, copy, move, lock, unlock, report,
}
/// An HTTP header. Host-controlled transport headers cannot be overridden.
record header { name: string, value: string }
/// Request head. At most 64 headers of 8 KiB each; the body is streamed.
record request { method: method, url: string, headers: list<header> }
/// Response head. The body is read from the exchange that produced it.
record response { status: u16, headers: list<header> }
/// One HTTP request and its response, streamed in bounded chunks so a
/// transfer is never held whole in memory. A body of any size crosses in
/// pieces, which is what multipart uploads of at least 5 MiB require.
/// Dropping the handle abandons the request.
resource exchange {
/// Appends at most 1,048,576 bytes to the request body. Every push
/// must land before `respond`.
push: func(data: list<u8>) -> result<_, host-error>;
/// Ends the request body and waits for the response head. Calling it
/// twice is invalid-input.
respond: func() -> result<response, host-error>;
/// Reads at most 1,048,576 bytes of the response body, after
/// `respond`. An empty result means the body is complete.
pull: func(limit: u32) -> result<list<u8>, host-error>;
}
/// Begins a request to a host both declared by the package and granted on
/// the connection. Each redirect and DNS result is checked, and DNS is
/// pinned. Private/LAN addresses additionally require the private-network
/// grant. Cross-origin redirects remove request headers; HTTPS cannot
/// downgrade. The host bounds total bytes per exchange, not buffer size.
http: func(request: request) -> result<exchange, host-error>;
/// One open TCP connection. Dropping the handle closes the socket, and the
/// host closes every socket when the connection is disposed.
resource tcp {
/// Reads at most `limit` bytes, between 1 and 1,048,576. An empty
/// result means the peer closed its side, not that data is pending.
read: func(limit: u32) -> result<list<u8>, host-error>;
/// Writes at most 1,048,576 bytes and returns how many were accepted.
write: func(data: list<u8>) -> result<u32, host-error>;
}
/// Opens a socket to a host and port both declared by the package and
/// granted on the connection. The host resolves and pins DNS, applies the
/// same private-address rule as `http`, and starts TLS when asked. Ports
/// negotiated at runtime, such as an FTP data channel, still need a grant.
connect: func(host: string, port: u16, tls: bool) -> result<tcp, host-error>;
/// One datagram socket connected to a single granted peer.
resource udp {
/// Sends one datagram of at most 65,507 bytes.
send: func(data: list<u8>) -> result<u32, host-error>;
/// Receives one datagram of at most `limit` bytes. An empty result
/// means none arrived inside the host's wait, not that the peer left.
receive: func(limit: u32) -> result<list<u8>, host-error>;
}
/// Connects a datagram socket under the same declaration and grant rules
/// as `connect`, for the few filesystem protocols that still need one.
bind: func(host: string, port: u16) -> result<udp, host-error>;
/// A configuration value, typed as the package's manifest declared it.
/// A secret is never logged by the host and never leaves the connection.
variant setting { text(string), secret(string), toggle(bool) }
/// Reads configuration or a session-only credential for this connection.
config: func(key: string) -> option<setting>;
/// Reports that the current access token was rejected. The host refreshes
/// it once; a second rejection surfaces to the caller as denied.
invalidate-token: func();
/// Emits a diagnostic event. Never include secrets or private document data.
log: func(level: log-level, message: string);
}
/// Filesystem operations for one connection. Paths are absolute within its
/// root, starting with `/`; they are never local OS paths or resource URIs.
/// Dot segments, backslashes, percent escapes, colons, and controls are invalid.
/// Every invocation is bounded by host fuel, memory, time, and cancellation.
interface provider {
use host.{setting};
/// Stable operation failures. A mutation interrupted after dispatch can
/// report outcome-unknown: inspect remote state before deciding to retry.
enum error-code {
not-found, denied, unsupported, conflict, unavailable, invalid-input,
cancelled, outcome-unknown, internal
}
/// Anything that is neither a file nor a directory, such as a link, is
/// reported as other rather than flattened into one of the two.
enum entry-kind { file, directory, other }
/// An opaque version token, at most 4096 UTF-8 bytes.
type revision = string;
/// An opaque backend identity, at most 4096 UTF-8 bytes. The host never
/// parses, orders, or shows one, and never keeps one past the connection,
/// because a backend may reuse it.
type resource-id = string;
/// How one resource is named. A provider that hands out ids must accept
/// them back; one that does not may reject every id as invalid-input.
variant locator { path(string), id(resource-id) }
/// Optional operations. False capabilities must return unsupported.
/// Conditional-write promises atomic create-only and revision comparisons,
/// and cannot be combined with staged-write, where nothing to compare
/// exists until a publish lands. Virtual-directories says a directory is
/// only ever a prefix of something stored, as in a flat object store: the
/// host then never offers to create an empty one, warns that removing the
/// last child removes the folder, and does not call a vanished folder an
/// error. Such a provider may still accept mkdir by writing a marker, and
/// must answer unsupported when asked to rename a directory.
record capabilities {
write: bool, mkdir: bool, rename: bool, remove: bool,
conditional-write: bool, watch: bool,
staged-write: bool, publishable: bool,
virtual-directories: bool,
}
/// Byte length, an opaque revision, and a backend identity, each if known.
record file-info {
kind: entry-kind,
size: u64,
revision: option<revision>,
id: option<resource-id>,
}
/// One child name, without slashes, dot segments, or control characters.
record entry { name: string, info: file-info }
/// At most the requested number of entries. A cursor (maximum 4096 bytes)
/// identifies another page; absence means enumeration is complete.
record list-page { entries: list<entry>, cursor: option<string> }
/// Atomic preconditions: absent file, exact opaque revision, or unconditional
/// replacement. Providers must never simulate comparisons non-atomically.
variant write-condition { create-only, if-match(revision), overwrite }
/// Changes since a previous polling cursor.
enum change-kind { created, modified, removed }
/// A changed resource path relative to the connection root.
record change { path: string, kind: change-kind }
/// At most 256 changes and a cursor of at most 4096 UTF-8 bytes.
record change-page { changes: list<change>, cursor: option<string> }
/// One transfer being read. The handle survives between host calls, so a
/// file of any size is read as a series of separately bounded calls.
/// Dropping it releases whatever the provider held open.
resource reader {
/// Reads onward from the last position, at most `limit` bytes, with
/// `limit` between 1 and 1,048,576. An empty result means end of file.
pull: func(limit: u32) -> result<list<u8>, error-code>;
/// The revision this transfer is reading, once the provider knows it.
/// A provider that sees the revision change mid-transfer must fail the
/// next pull with conflict rather than mix two versions.
revision: func() -> option<revision>;
}
/// One transfer being written. Dropping the handle before it finishes
/// abandons the transfer, and the provider must leave no partial file.
resource writer {
/// Appends at most 1,048,576 bytes. Oversized pushes are rejected,
/// never truncated.
push: func(data: list<u8>) -> result<_, error-code>;
/// Commits the transfer and evaluates the write condition now, against
/// the state the caller last saw. A none result means the commit needs
/// another call: keep calling until it answers some, and treat an
/// interruption in between as outcome-unknown.
finish: func() -> result<option<file-info>, error-code>;
}
/// Opens exactly one host-owned connection per component instance. The host
/// passes only this connection's configuration and in-memory credentials.
open: func(provider-id: string, config: list<tuple<string, setting>>) -> result<capabilities, error-code>;
/// Best-effort cleanup before disposal. The host may dispose immediately
/// after a trap, cancellation, permission change, or process shutdown.
close: func();
/// Reads metadata without changing the resource.
stat: func(target: locator) -> result<file-info, error-code>;
/// Enumerates 1-256 direct children per request, without recursive traversal.
%list: func(target: locator, cursor: option<string>, limit: u32) -> result<list-page, error-code>;
/// Begins reading at a byte offset. Streaming, rather than one capped read,
/// is what lets a provider serve a file larger than a single call allows.
open-read: func(target: locator, offset: u64) -> result<reader, error-code>;
/// Begins replacing one complete file if the write capability is present.
/// The size hint, when the caller knows it, lets a provider choose between
/// a single request and a multipart upload before the first byte.
open-write: func(target: locator, condition: write-condition, size-hint: option<u64>) -> result<writer, error-code>;
/// Creates one directory if the mkdir capability is present.
mkdir: func(target: locator) -> result<file-info, error-code>;
/// Renames within this connection if the rename capability is present.
rename: func(target: locator, destination: string) -> result<file-info, error-code>;
/// Removes one file or empty directory if the remove capability is present.
remove: func(target: locator) -> result<_, error-code>;
/// Polls for a bounded change page if the watch capability is present. No
/// background callback or persistent subscription is installed by the host.
poll: func(cursor: option<string>) -> result<change-page, error-code>;
/// Makes every staged write visible as one new root, if the publishable
/// capability is present, and names it, for example as a content
/// identifier. A none result means the publish needs another call.
publish: func() -> result<option<revision>, error-code>;
}
/// Memona filesystem extension ABI 0.1.0, distributed as a WebAssembly component.
/// Rust guests can target wasm32-wasip2. Selected WASI 0.2 CLI, clocks, random,
/// IO and filesystem interfaces are allowed for the standard library, with
/// empty arguments/environment, empty preopens, and discarded stdio. WASI
/// sockets and HTTP imports are not allowed; networking uses the host interface.
world filesystem {
import host;
export provider;
}