Open a WebDAV server as a folder, starting from the WebDAV plugin's code

This commit is contained in:
Danny Kim
2026-09-26 01:34:51 +09:00
parent 5d591b2fb0
commit 57347acdb6
11 changed files with 1510 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-signature"
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-signature"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "A WebDAV folder for Memona that signs Markdown files with an ENS name, built as a WASI component."
repository = "https://git.capsulizers.com/commons/memona-plugin-signature"
[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
+60
View File
@@ -5,3 +5,63 @@ saved to it with an ENS name, writing `signer` and `signature` into the file's
frontmatter so anyone can check who wrote it against the name's current ENSv2 frontmatter so anyone can check who wrote it against the name's current ENSv2
records. Signing and verification come from the records. Signing and verification come from the
[md-ens-signature](https://github.com/capsulizers/md-ens-signature) library. [md-ens-signature](https://github.com/capsulizers/md-ens-signature) library.
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, `signature`, for the `signed-webdav` scheme.
## 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 behave
as in the WebDAV plugin: reads and writes stream in pieces of at most 1 MiB,
conditional writes use `If-Match` and `If-None-Match`, change polling is not
supported, a rename never replaces an existing target, and a folder with
contents is refused rather than removed whole.
## Building
```sh
rustup target add wasm32-wasip2
cargo build --locked --release --target wasm32-wasip2
cargo test --locked --lib
```
The component lands at
`target/wasm32-wasip2/release/memona_plugin_signature.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_signature.wasm plugin.wasm
zip memona-plugin-signature.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.
## Attribution
The WebDAV code in `src/lib.rs`, `src/dav.rs` and `src/sdk.rs` is copied from
the MIT-licensed
[Memona WebDAV plugin](https://git.capsulizers.com/commons/memona-plugin-webdav)
at commit `3d69750`. That crate keeps its provider, reader and writer private
and exports the component's entry points itself, so it cannot be wrapped as a
dependency without forking it; the copy stays as close to the original as the
signing hook allows, so fixes there carry over with a plain diff.
+40
View File
@@ -0,0 +1,40 @@
{
"schemaVersion": 1,
"id": "memona/signature",
"name": "Signature",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Open a WebDAV server as a folder, and sign the Markdown saved to it with an ENS name.",
"license": "MIT",
"entry": "plugin.wasm",
"networkHosts": ["*"],
"networkPorts": [],
"providers": [
{
"id": "signature",
"name": "Signed WebDAV",
"schemes": ["signed-webdav"],
"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
}
]
}
]
}
+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;
+200
View File
@@ -0,0 +1,200 @@
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>;
/// 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;
/// How one resource is named: a path within the connection root.
variant locator { path(string) }
/// Optional operations. False capabilities must return unsupported.
/// Conditional-write promises atomic create-only and revision comparisons.
/// 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,
virtual-directories: bool,
}
/// Byte length and an opaque revision, each if known.
record file-info {
kind: entry-kind,
size: u64,
revision: option<revision>,
}
/// 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>;
}
/// 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;
}