commit bca12b4c2e1e287dcb8726ef1d9cd190251650a4 Author: Danny Kim Date: Fri Sep 25 11:57:03 2026 +0900 Open a WebDAV server as a folder in Memona diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..875aaee --- /dev/null +++ b/.clippy.toml @@ -0,0 +1 @@ +warn-on-all-wildcard-imports = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..33043ba --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,3 @@ +style_edition = "2024" +max_width = 80 +tab_spaces = 2 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..eacf6b0 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..908d919 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f5540b0 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffea54a --- /dev/null +++ b/README.md @@ -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. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..404f57d --- /dev/null +++ b/manifest.json @@ -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 + } + ] + } + ] +} diff --git a/src/dav.rs b/src/dav.rs new file mode 100644 index 0000000..7e06dab --- /dev/null +++ b/src/dav.rs @@ -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, +} + +/// 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, 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 = 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#" + + + /files/notes/ + + HTTP/1.1 200 OK + + + /files/notes/a%20b.md + + + 42 + "v1" + HTTP/1.1 200 OK + + "#; + 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(()) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ad50eae --- /dev/null +++ b/src/lib.rs @@ -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#""#, + r#""#, + "", + "", +); +const MAX_CHUNK: u32 = 1024 * 1024; + +struct Session { + root: String, + authorization: Option, +} + +thread_local! { + static SESSION: RefCell> = const { RefCell::new(None) }; +} + +struct Webdav; + +fn text(setting: Option) -> Option { + 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 { + match target { + Locator::Path(path) => Ok(path.clone()), + Locator::Id(_) => Err(ErrorCode::InvalidInput), + } +} + +fn session( + read: impl FnOnce(&Session) -> Result, +) -> Result { + SESSION.with_borrow(|session| match session { + Some(session) => read(session), + None => Err(ErrorCode::Internal), + }) +} + +/// Starts one request with this connection's credentials attached. +fn begin( + method: host::Method, + path: &str, + extra: Vec, +) -> Result { + let (url, authorization) = session(|session| { + Ok((dav::url(&session.root, path), session.authorization.clone())) + })?; + let mut headers = extra; + if let Some(value) = authorization { + headers.push(host::Header { + name: "authorization".to_owned(), + value, + }); + } + host::http(&host::Request { + method, + url, + headers, + }) + .map_err(transport) +} + +/// Runs one request that carries a small body and answers a small body. +fn exchange( + method: host::Method, + path: &str, + extra: Vec, + body: &[u8], +) -> Result, ErrorCode> { + let call = begin(method, path, extra)?; + if !body.is_empty() { + call.push(body).map_err(transport)?; + } + let head = call.respond().map_err(transport)?; + dav::outcome(head.status)?; + let mut data = Vec::new(); + loop { + let chunk = call.pull(MAX_CHUNK).map_err(transport)?; + if chunk.is_empty() { + return Ok(data); + } + data.extend_from_slice(&chunk); + if data.len() > 8 * 1024 * 1024 { + return Err(ErrorCode::Unavailable); + } + } +} + +fn depth(value: &str) -> host::Header { + host::Header { + name: "depth".to_owned(), + value: value.to_owned(), + } +} + +fn content_type() -> host::Header { + host::Header { + name: "content-type".to_owned(), + value: "application/xml; charset=utf-8".to_owned(), + } +} + +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, +} + +impl GuestReader for DavReader { + fn pull(&self, limit: u32) -> Result, ErrorCode> { + self.call.pull(limit.min(MAX_CHUNK)).map_err(transport) + } + fn revision(&self) -> Option { + self.revision.clone() + } +} + +/// Writes one file through a PUT whose body is pushed in pieces. +struct DavWriter { + call: host::Exchange, + done: RefCell, +} + +impl GuestWriter for DavWriter { + fn push(&self, data: Vec) -> Result<(), ErrorCode> { + if *self.done.borrow() { + return Err(ErrorCode::InvalidInput); + } + self.call.push(&data).map_err(transport) + } + /// One call is enough: the request body is already complete, so the response + /// head settles the write. The etag it returns, when the server sends one, + /// is the revision a later conditional write compares against. + fn finish(&self) -> Result, ErrorCode> { + if *self.done.borrow() { + return Err(ErrorCode::InvalidInput); + } + *self.done.borrow_mut() = true; + let head = self.call.respond().map_err(transport)?; + dav::outcome(head.status)?; + let revision = head + .headers + .iter() + .find(|header| header.name.eq_ignore_ascii_case("etag")) + .map(|header| header.value.clone()); + Ok(Some(FileInfo { + kind: EntryKind::File, + size: 0, + revision, + id: None, + })) + } +} + +impl Guest for Webdav { + type Reader = DavReader; + type Writer = DavWriter; + + fn open( + _provider_id: String, + config: Vec<(String, Setting)>, + ) -> Result { + let lookup = |wanted: &str| { + config + .iter() + .find(|(key, _)| key == wanted) + .and_then(|(_, value)| text(Some(value.clone()))) + }; + let root = lookup("url").ok_or(ErrorCode::InvalidInput)?; + if !root.starts_with("https://") && !root.starts_with("http://") { + return Err(ErrorCode::InvalidInput); + } + let authorization = 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 { + 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, + limit: u32, + ) -> Result { + 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 { + 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, + ) -> Result { + let path = route(&target)?; + let guard = match condition { + WriteCondition::CreateOnly => Some(host::Header { + name: "if-none-match".to_owned(), + value: "*".to_owned(), + }), + WriteCondition::IfMatch(revision) => Some(host::Header { + name: "if-match".to_owned(), + value: revision, + }), + WriteCondition::Overwrite => None, + }; + let call = begin(host::Method::Put, &path, guard.into_iter().collect())?; + Ok(Writer::new(DavWriter { + call, + done: RefCell::new(false), + })) + } + + fn mkdir(target: Locator) -> Result { + let path = route(&target)?; + exchange(host::Method::Mkcol, &path, Vec::new(), &[])?; + Ok(FileInfo { + kind: EntryKind::Directory, + size: 0, + revision: None, + id: None, + }) + } + + fn rename( + target: Locator, + destination: String, + ) -> Result { + let path = route(&target)?; + let to = session(|session| Ok(dav::url(&session.root, &destination)))?; + exchange( + host::Method::Move, + &path, + vec![ + host::Header { + name: "destination".to_owned(), + value: to, + }, + // Never clobber an existing target: a rename that would replace one is + // a conflict for the caller to resolve. + host::Header { + name: "overwrite".to_owned(), + value: "F".to_owned(), + }, + ], + &[], + )?; + Self::stat(Locator::Path(destination)) + } + + fn remove(target: Locator) -> Result<(), ErrorCode> { + let path = route(&target)?; + exchange(host::Method::Delete, &path, Vec::new(), &[])?; + Ok(()) + } + + fn poll(_cursor: Option) -> Result { + Err(ErrorCode::Unsupported) + } + + fn publish() -> Result, ErrorCode> { + Err(ErrorCode::Unsupported) + } +} + +sdk::export!(Webdav with_types_in sdk); diff --git a/src/sdk.rs b/src/sdk.rs new file mode 100644 index 0000000..668b629 --- /dev/null +++ b/src/sdk.rs @@ -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; diff --git a/wit/filesystem.wit b/wit/filesystem.wit new file mode 100644 index 0000000..560695e --- /dev/null +++ b/wit/filesystem.wit @@ -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
} + /// Response head. The body is read from the exchange that produced it. + record response { status: u16, headers: list
} + /// 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) -> result<_, host-error>; + /// Ends the request body and waits for the response head. Calling it + /// twice is invalid-input. + respond: func() -> result; + /// 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, 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; + + /// 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, host-error>; + /// Writes at most 1,048,576 bytes and returns how many were accepted. + write: func(data: list) -> result; + } + /// 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; + + /// One datagram socket connected to a single granted peer. + resource udp { + /// Sends one datagram of at most 65,507 bytes. + send: func(data: list) -> result; + /// 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, 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; + + /// 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; + /// 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, + id: option, + } + /// 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, cursor: option } + /// 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, cursor: option } + + /// 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, 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; + } + /// 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) -> 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, 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>) -> result; + /// 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; + /// Enumerates 1-256 direct children per request, without recursive traversal. + %list: func(target: locator, cursor: option, limit: u32) -> result; + /// 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; + /// 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) -> result; + /// Creates one directory if the mkdir capability is present. + mkdir: func(target: locator) -> result; + /// Renames within this connection if the rename capability is present. + rename: func(target: locator, destination: string) -> result; + /// 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) -> result; + /// 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, 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; +}