From 57347acdb6048b2d69d447d0e54045f47bde19c1 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Sat, 26 Sep 2026 01:34:51 +0900 Subject: [PATCH 1/3] Open a WebDAV server as a folder, starting from the WebDAV plugin's code --- .clippy.toml | 1 + .gitignore | 1 + .rustfmt.toml | 3 + Cargo.lock | 336 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 23 +++ README.md | 60 +++++++ manifest.json | 40 +++++ src/dav.rs | 417 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 414 ++++++++++++++++++++++++++++++++++++++++++++ src/sdk.rs | 15 ++ wit/filesystem.wit | 200 ++++++++++++++++++++++ 11 files changed, 1510 insertions(+) create mode 100644 .clippy.toml create mode 100644 .gitignore create mode 100644 .rustfmt.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 manifest.json create mode 100644 src/dav.rs create mode 100644 src/lib.rs create mode 100644 src/sdk.rs create mode 100644 wit/filesystem.wit 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..1d407b9 --- /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-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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2a25cd2 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md index 0b3cdc5..480a077 100644 --- a/README.md +++ b/README.md @@ -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 records. Signing and verification come from the [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. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7ad1a43 --- /dev/null +++ b/manifest.json @@ -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 + } + ] + } + ] +} diff --git a/src/dav.rs b/src/dav.rs new file mode 100644 index 0000000..8b8dae4 --- /dev/null +++ b/src/dav.rs @@ -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 { + 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, + 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, ErrorCode>, + ) -> Result, 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, +} + +/// 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() +} + +/// 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::::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::::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#" + + + /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..a365775 --- /dev/null +++ b/src/lib.rs @@ -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#""#, + 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, the only way the contract addresses a resource. +fn route(target: &Locator) -> String { + let Locator::Path(path) = target; + path.clone() +} + +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(), + } +} + +/// Asks the server about one path, and its children when `depth` is 1. +fn describe( + path: &str, + depth_value: &str, +) -> Result, 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, + body: RefCell, +} + +impl GuestReader for DavReader { + fn pull(&self, limit: u32) -> Result, ErrorCode> { + self + .body + .borrow_mut() + .pull(limit.min(MAX_CHUNK) as usize, |wanted| { + self.call.pull(wanted).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, + written: Cell, +} + +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)?; + 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, 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 { + 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 { + 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, + limit: u32, + ) -> Result { + 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 { + 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, + ) -> 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), + written: Cell::new(0), + })) + } + + 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, + }) + } + + 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)) + } + + /// 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) -> Result { + 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..4529372 --- /dev/null +++ b/wit/filesystem.wit @@ -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
} + /// 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; + + /// 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, + } + /// 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; +} + +/// 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; +} -- 2.52.0 From 7f4e4e97194e9d0fde436bf568d2c302c9debbe7 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Sat, 26 Sep 2026 01:40:01 +0900 Subject: [PATCH 2/3] Let the AI bot review pull requests --- .gitea/workflows/agents.yml | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .gitea/workflows/agents.yml diff --git a/.gitea/workflows/agents.yml b/.gitea/workflows/agents.yml new file mode 100644 index 0000000..68332dc --- /dev/null +++ b/.gitea/workflows/agents.yml @@ -0,0 +1,48 @@ +name: Agents +run-name: >- + AI bot at + #${{ gitea.event.issue.number || gitea.event.pull_request.number }} + +on: + issues: + types: [opened] + pull_request: + types: [opened, review_requested] + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +concurrency: + group: >- + agents-${{ gitea.event.sender.login == 'bot' && gitea.run_id || + gitea.event.issue.number || gitea.event.pull_request.number }} + cancel-in-progress: true + +jobs: + agent: + if: >- + gitea.actor != 'bot' && gitea.event.sender.login != 'bot' && + ((gitea.event_name == 'issues' && + contains(gitea.event.issue.body, '@bot')) || + (gitea.event_name == 'pull_request' && + ((gitea.event.action == 'opened' && + contains(gitea.event.pull_request.body, '@bot')) || + gitea.event.requested_reviewer.login == 'bot')) || + (gitea.event_name == 'issue_comment' && + contains(gitea.event.comment.body, '@bot')) || + (gitea.event_name == 'pull_request_review_comment' && + contains(gitea.event.review.content, '@bot'))) + permissions: + code: write + issues: write + pull-requests: write + runs-on: self-hosted + container: + volumes: + - bot-home:/root + steps: + - name: Run AI bot + uses: https://git.capsulizers.com/commons/bot-agents@main + with: + master-key: ${{ secrets.BOT_AGENTS_MASTER_KEY }} -- 2.52.0 From 3cd09971564451d09bff435a37b67ced37348e2e Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Sat, 26 Sep 2026 01:45:11 +0900 Subject: [PATCH 3/3] Say the scaffold is a WebDAV folder that does not sign yet --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 480a077..91dce9c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # Memona signature plugin -Opens a WebDAV server as a folder inside Memona and signs every Markdown file -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 -records. Signing and verification come from the +Opens a WebDAV server as a folder inside Memona. It is the foundation for +signing Markdown files with an ENS name as they are saved, writing `signer` and +`signature` into each file's frontmatter so anyone can check who wrote it +against the name's current ENSv2 records, using the [md-ens-signature](https://github.com/capsulizers/md-ens-signature) library. +This version is the WebDAV folder alone; signing is not in it yet. 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. -- 2.52.0