From b796cd462280d75b917ce79cd6a6210e31c0a82f Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 15:26:51 +0900 Subject: [PATCH 01/10] Validate WebDAV connections and match directory responses by path --- Cargo.lock | 28 +++++++++++ Cargo.toml | 3 ++ src/dav.rs | 141 +++++++++++++++++++++++++++++++++++++++++++---------- src/lib.rs | 54 ++++++++++++++++++-- 4 files changed, 198 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0b5338..926d24f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,9 +99,16 @@ name = "memona-plugin-webdav" version = "0.1.1" dependencies = [ "quick-xml", + "tokio", "wit-bindgen", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "prettyplease" version = "0.3.0" @@ -198,6 +205,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/Cargo.toml b/Cargo.toml index 960aa8c..c122f84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ crate-type = ["cdylib", "rlib"] wit-bindgen = "=0.62.0" quick-xml = { version = "0.37", default-features = false } +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } + [lints.clippy] panic = "deny" unwrap_used = "deny" diff --git a/src/dav.rs b/src/dav.rs index 8b8dae4..611a832 100644 --- a/src/dav.rs +++ b/src/dav.rs @@ -23,6 +23,9 @@ fn segment(value: &str) -> String { /// Joins a connection-relative path onto the configured root. pub fn url(root: &str, path: &str) -> String { + if path == "/" || path.is_empty() { + return root.to_owned(); + } let mut url = root.trim_end_matches('/').to_owned(); for part in path.split('/').filter(|part| !part.is_empty()) { url.push('/'); @@ -238,23 +241,62 @@ pub fn name_of(href: &str) -> String { 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), +/// An href can be an absolute URL or an absolute path (RFC 4918, section 8.3). +fn href_path(href: &str) -> &str { + let path = match href.split_once("://") { + Some((_, authority_and_path)) => authority_and_path + .find('/') + .map(|start| &authority_and_path[start..]) + .unwrap_or("/"), + None => href, + }; + path + .split(['?', '#']) + .next() + .unwrap_or(path) + .trim_end_matches('/') +} + +/// Responses may arrive in any order. Match the requested resource by its +/// path, allowing collection slashes and equivalent percent encodings. +pub fn resource<'a>( + found: &'a [Found], + url: &str, +) -> Result<&'a Found, ErrorCode> { + found + .iter() + .find(|entry| { + href_path(&entry.href) + .split('/') + .map(name_of) + .eq(href_path(url).split('/').map(name_of)) + }) + .ok_or(ErrorCode::NotFound) +} + +/// The direct children in a Depth 1 reply, excluding the requested directory. +pub fn children<'a>( + found: &'a [Found], + url: &str, +) -> Result, ErrorCode> { + let parent = resource(found, url)?; + if !parent.collection { + return Err(ErrorCode::InvalidInput); } + Ok( + found + .iter() + .filter(|entry| !std::ptr::eq(*entry, parent)) + .collect(), + ) } /// 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(()), +pub fn removable(found: &[Found], url: &str) -> Result<(), ErrorCode> { + match children(found, url) { + Ok(children) if children.is_empty() => Ok(()), Ok(_) => Err(ErrorCode::Conflict), Err(ErrorCode::InvalidInput) => Ok(()), Err(error) => Err(error), @@ -265,24 +307,24 @@ pub fn removable(found: &[Found]) -> Result<(), ErrorCode> { mod tests { use super::{ Body, Found, basic, children, multistatus, name_of, outcome, range_outcome, - removable, url, + removable, resource, url, }; use crate::sdk::ErrorCode; - fn found(collection: bool) -> Found { + fn found(href: &str, collection: bool) -> Found { Found { - href: String::new(), + href: href.to_owned(), collection, length: 0, etag: None, } } - #[test] - fn paths_join_onto_the_root_without_growing_a_boundary() { + #[tokio::test] + async fn paths_join_onto_the_root_without_growing_a_boundary() { assert_eq!( url("https://dav.example/files/", "/"), - "https://dav.example/files" + "https://dav.example/files/" ); assert_eq!( url("https://dav.example/files", "/notes/today.md"), @@ -299,6 +341,46 @@ mod tests { ); } + #[tokio::test] + async fn listings_keep_children_when_the_directory_is_not_first() + -> Result<(), ErrorCode> { + let entries = [found("/files/a.md", false), found("/files/", true)]; + let listed = children(&entries, "https://dav.example/files/")?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].href, "/files/a.md"); + Ok(()) + } + + #[tokio::test] + async fn a_file_before_its_directory_never_allows_recursive_delete() { + let entries = [found("/files/a.md", false), found("/files/", true)]; + assert_eq!( + removable(&entries, "https://dav.example/files/"), + Err(ErrorCode::Conflict) + ); + } + + #[tokio::test] + async fn hrefs_match_absolute_urls_and_equivalent_encoded_paths() + -> Result<(), ErrorCode> { + let entries = [ + found("https://dav.example/my%20files/%e9%9b%aa.md", false), + found("https://dav.example/my%20files/", true), + ]; + let listed = children(&entries, "https://dav.example/my%20files")?; + assert_eq!(listed.len(), 1); + assert_eq!(name_of(&listed[0].href), "雪.md"); + assert!( + !resource(&entries, "https://dav.example/my%20files/%E9%9B%AA.md")? + .collection + ); + assert_eq!( + removable(&entries, "https://dav.example/unrelated/"), + Err(ErrorCode::NotFound) + ); + Ok(()) + } + #[test] fn credentials_encode_the_way_basic_auth_expects() { assert_eq!(basic("user", "pass"), "Basic dXNlcjpwYXNz"); @@ -366,21 +448,30 @@ mod tests { 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(())); + #[tokio::test] + async fn only_an_empty_directory_or_a_file_may_be_removed() { + let url = "https://dav.example/files/"; + assert_eq!(removable(&[found("/files/", false)], url), Ok(())); + assert_eq!(removable(&[found("/files/", true)], url), Ok(())); assert_eq!( - removable(&[found(true), found(false)]), + removable(&[found("/files/", true), found("/files/a.md", false)], url), Err(ErrorCode::Conflict) ); - assert_eq!(removable(&[]), Err(ErrorCode::NotFound)); + assert_eq!(removable(&[], url), Err(ErrorCode::NotFound)); assert_eq!( - children(&[found(false)]).err(), + children(&[found("/files/", false)], url).err(), Some(ErrorCode::InvalidInput) ); assert_eq!( - children(&[found(true), found(false), found(true)]).map(<[Found]>::len), + children( + &[ + found("/files/", true), + found("/files/a.md", false), + found("/files/notes/", true) + ], + url, + ) + .map(|children| children.len()), Ok(2) ); } diff --git a/src/lib.rs b/src/lib.rs index fef0338..8a4e06b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,10 +34,11 @@ struct Webdav; fn text(setting: Option) -> Option { match setting { - Some(Setting::Text(value)) | Some(Setting::Secret(value)) => { + Some(Setting::Text(value)) => { let value = value.trim().to_owned(); (!value.is_empty()).then_some(value) } + Some(Setting::Secret(value)) => Some(value), Some(Setting::Toggle(_)) | None => None, } } @@ -225,6 +226,7 @@ impl Guest for Webdav { _provider_id: String, config: Vec<(String, Setting)>, ) -> Result { + SESSION.set(None); let lookup = |wanted: &str| { config .iter() @@ -245,6 +247,17 @@ impl Guest for Webdav { root, authorization, })); + let probe = Self::stat(Locator::Path("/".to_owned())).and_then(|info| { + if matches!(info.kind, EntryKind::Directory) { + Ok(()) + } else { + Err(ErrorCode::InvalidInput) + } + }); + if let Err(error) = probe { + SESSION.set(None); + return Err(error); + } Ok(Capabilities { write: true, mkdir: true, @@ -281,7 +294,8 @@ impl Guest for Webdav { None => 0, }; let found = describe(&path, "1")?; - let children: Vec<_> = dav::children(&found)? + let url = session(|session| Ok(dav::url(&session.root, &path)))?; + let children: Vec<_> = dav::children(&found, &url)? .iter() .map(|entry| Entry { name: dav::name_of(&entry.href), @@ -400,7 +414,8 @@ impl Guest for Webdav { /// first and refused unless it is empty. fn remove(target: Locator) -> Result<(), ErrorCode> { let path = route(&target); - dav::removable(&describe(&path, "1")?)?; + let url = session(|session| Ok(dav::url(&session.root, &path)))?; + dav::removable(&describe(&path, "1")?, &url)?; exchange(host::Method::Delete, &path, Vec::new(), &[])?; Ok(()) } @@ -411,3 +426,36 @@ impl Guest for Webdav { } sdk::export!(Webdav with_types_in sdk); + +#[cfg(test)] +mod tests { + use super::{SESSION, Session, Webdav, text}; + use crate::sdk::{ErrorCode, Guest, Setting}; + + #[tokio::test] + async fn passwords_keep_leading_and_trailing_whitespace() { + assert_eq!( + text(Some(Setting::Secret(" pass word \t".to_owned()))), + Some(" pass word \t".to_owned()) + ); + } + + #[tokio::test] + async fn an_empty_password_still_supplies_basic_auth_credentials() { + assert_eq!( + text(Some(Setting::Secret(String::new()))), + Some(String::new()) + ); + } + + #[tokio::test] + async fn a_failed_reopen_discards_the_previous_connection() { + SESSION.set(Some(Session { + root: "https://dav.example/files/".to_owned(), + authorization: None, + })); + let result = Webdav::open("webdav".to_owned(), Vec::new()); + assert_eq!(result.err(), Some(ErrorCode::InvalidInput)); + assert!(SESSION.with_borrow(Option::is_none)); + } +} -- 2.52.0 From 4ec7e53ab539859f545a5e129f7b4443c54b224f Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 15:28:48 +0900 Subject: [PATCH 02/10] Authenticate configured WebDAV users with omitted empty passwords --- src/lib.rs | 93 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 27 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8a4e06b..d8bedba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,28 @@ struct Session { authorization: Option, } +impl Session { + fn from_config(config: &[(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 = lookup("username").map(|username| { + dav::basic(&username, &lookup("password").unwrap_or_default()) + }); + Ok(Self { + root, + authorization, + }) + } +} + thread_local! { static SESSION: RefCell> = const { RefCell::new(None) }; } @@ -227,26 +249,7 @@ impl Guest for Webdav { config: Vec<(String, Setting)>, ) -> Result { SESSION.set(None); - 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, - })); + SESSION.set(Some(Session::from_config(&config)?)); let probe = Self::stat(Locator::Path("/".to_owned())).and_then(|info| { if matches!(info.kind, EntryKind::Directory) { Ok(()) @@ -429,23 +432,59 @@ sdk::export!(Webdav with_types_in sdk); #[cfg(test)] mod tests { - use super::{SESSION, Session, Webdav, text}; + use super::{SESSION, Session, Webdav}; use crate::sdk::{ErrorCode, Guest, Setting}; #[tokio::test] - async fn passwords_keep_leading_and_trailing_whitespace() { + async fn passwords_keep_leading_and_trailing_whitespace() + -> Result<(), ErrorCode> { + let config = vec![ + ( + "url".to_owned(), + Setting::Text("https://dav.example/".to_owned()), + ), + ("username".to_owned(), Setting::Text("user".to_owned())), + ( + "password".to_owned(), + Setting::Secret(" pass word \t".to_owned()), + ), + ]; assert_eq!( - text(Some(Setting::Secret(" pass word \t".to_owned()))), - Some(" pass word \t".to_owned()) + Session::from_config(&config)?.authorization.as_deref(), + Some("Basic dXNlcjogcGFzcyB3b3JkIAk=") ); + Ok(()) } #[tokio::test] - async fn an_empty_password_still_supplies_basic_auth_credentials() { + async fn a_username_without_a_password_setting_sends_basic_auth() + -> Result<(), ErrorCode> { + let config = vec![ + ( + "url".to_owned(), + Setting::Text("https://dav.example/".to_owned()), + ), + ("username".to_owned(), Setting::Text("user".to_owned())), + ]; assert_eq!( - text(Some(Setting::Secret(String::new()))), - Some(String::new()) + Session::from_config(&config)?.authorization.as_deref(), + Some("Basic dXNlcjo=") ); + Ok(()) + } + + #[tokio::test] + async fn no_username_keeps_the_connection_anonymous() -> Result<(), ErrorCode> + { + let config = vec![ + ( + "url".to_owned(), + Setting::Text("https://dav.example/".to_owned()), + ), + ("password".to_owned(), Setting::Secret("unused".to_owned())), + ]; + assert!(Session::from_config(&config)?.authorization.is_none()); + Ok(()) } #[tokio::test] -- 2.52.0 From 3e7d5dda8b749a1797f8d075aaecfeaf8e46eb04 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 15:30:52 +0900 Subject: [PATCH 03/10] Check plugin builds and request AI review in Gitea --- .gitea/workflows/agents.yml | 49 +++++++++++++++++++++++++++++++++++ .gitea/workflows/ci.yml | 51 +++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 .gitea/workflows/agents.yml create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/agents.yml b/.gitea/workflows/agents.yml new file mode 100644 index 0000000..7f49767 --- /dev/null +++ b/.gitea/workflows/agents.yml @@ -0,0 +1,49 @@ +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] + +# New human events replace stale runs for the same work item. AI-bot comments +# get unique groups so their skipped workflows cannot cancel an active login. +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: + bot: + # Nothing the bot account posts can start a run, so it cannot trigger itself. + if: > + 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'))) + runs-on: self-hosted + container: + volumes: + - bot-home:/root + steps: + - name: Run AI bot + # Intentionally follow protected main so centrally managed profiles and + # quota fallbacks update without per-repository credential changes. + uses: https://git.capsulizers.com/commons/bot-agents@main + with: + master-key: ${{ secrets.BOT_AGENTS_MASTER_KEY }} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..32da818 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: self-hosted + container: + volumes: + - memona-plugin-target:/cargo-target + env: + CARGO_BUILD_JOBS: 2 + CARGO_TARGET_DIR: /cargo-target/serialized + CARGO_HOME: /cargo-target/cargo-home + RUSTUP_HOME: /cargo-target/rustup-home + RUSTUP_TOOLCHAIN: stable + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install system packages + run: >- + apt-get update && apt-get install -y --no-install-recommends + build-essential ca-certificates curl util-linux + + # The same lock protects the shared toolchain and every Cargo command. + # Keep its target and lock paths in sync with Memona's plugin checks. + - name: Check and build plugin + run: | + flock --no-fork "$CARGO_TARGET_DIR.lock" sh -ec ' + export PATH="$CARGO_HOME/bin:$PATH" + if [ ! -x "$CARGO_HOME/bin/rustup" ]; then + mkdir -p "$CARGO_HOME" + curl --proto =https --tlsv1.2 -fsSL https://sh.rustup.rs -o "$CARGO_HOME/rustup-init.sh" + sh "$CARGO_HOME/rustup-init.sh" -y --no-modify-path --default-toolchain none + rm "$CARGO_HOME/rustup-init.sh" + fi + rustup toolchain install stable --profile minimal --component clippy,rustfmt --target wasm32-wasip2 --no-self-update + cargo fmt --check + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked --lib + cargo build --locked --release --target wasm32-wasip2 + ' -- 2.52.0 From 402c1001e29b19f1627d0fbb6e78d1ef55f9e25a Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 15:31:54 +0900 Subject: [PATCH 04/10] Prepare WebDAV 0.1.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 926d24f..8806c61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,7 +96,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memona-plugin-webdav" -version = "0.1.1" +version = "0.1.2" dependencies = [ "quick-xml", "tokio", diff --git a/Cargo.toml b/Cargo.toml index c122f84..a8fadb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memona-plugin-webdav" -version = "0.1.1" +version = "0.1.2" edition = "2024" license = "MIT" description = "A WebDAV filesystem provider for Memona, built as a WASI component." diff --git a/manifest.json b/manifest.json index 977049e..e5b5712 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "memona/webdav", "name": "WebDAV", - "version": "0.1.1", + "version": "0.1.2", "apiVersion": "0.1.0", "description": "Open a WebDAV server as a folder, and let agents work in it.", "license": "MIT", -- 2.52.0 From bae6e3dd6659b6a0b5108ef6775d0e3633e7f931 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 15:34:12 +0900 Subject: [PATCH 05/10] Retain canonical WebDAV root href after alias redirects --- src/lib.rs | 110 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 102 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d8bedba..d408824 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,8 @@ const MAX_CHUNK: u32 = 1024 * 1024; struct Session { root: String, + /// The server's canonical href is only used to identify listing responses. + root_href: String, authorization: Option, } @@ -42,10 +44,20 @@ impl Session { dav::basic(&username, &lookup("password").unwrap_or_default()) }); Ok(Self { + root_href: root.clone(), root, authorization, }) } + + fn record_root(&mut self, found: &[dav::Found]) -> Result<(), ErrorCode> { + let root = found.first().ok_or(ErrorCode::NotFound)?; + if !root.collection || root.href.is_empty() { + return Err(ErrorCode::InvalidInput); + } + self.root_href.clone_from(&root.href); + Ok(()) + } } thread_local! { @@ -250,12 +262,13 @@ impl Guest for Webdav { ) -> Result { SESSION.set(None); SESSION.set(Some(Session::from_config(&config)?)); - let probe = Self::stat(Locator::Path("/".to_owned())).and_then(|info| { - if matches!(info.kind, EntryKind::Directory) { - Ok(()) - } else { - Err(ErrorCode::InvalidInput) - } + let probe = describe("/", "0").and_then(|found| { + SESSION.with_borrow_mut(|session| { + session + .as_mut() + .ok_or(ErrorCode::Internal)? + .record_root(&found) + }) }); if let Err(error) = probe { SESSION.set(None); @@ -297,7 +310,7 @@ impl Guest for Webdav { None => 0, }; let found = describe(&path, "1")?; - let url = session(|session| Ok(dav::url(&session.root, &path)))?; + let url = session(|session| Ok(dav::url(&session.root_href, &path)))?; let children: Vec<_> = dav::children(&found, &url)? .iter() .map(|entry| Entry { @@ -417,7 +430,7 @@ impl Guest for Webdav { /// first and refused unless it is empty. fn remove(target: Locator) -> Result<(), ErrorCode> { let path = route(&target); - let url = session(|session| Ok(dav::url(&session.root, &path)))?; + let url = session(|session| Ok(dav::url(&session.root_href, &path)))?; dav::removable(&describe(&path, "1")?, &url)?; exchange(host::Method::Delete, &path, Vec::new(), &[])?; Ok(()) @@ -433,6 +446,7 @@ sdk::export!(Webdav with_types_in sdk); #[cfg(test)] mod tests { use super::{SESSION, Session, Webdav}; + use crate::dav; use crate::sdk::{ErrorCode, Guest, Setting}; #[tokio::test] @@ -487,10 +501,90 @@ mod tests { Ok(()) } + #[tokio::test] + async fn a_redirected_root_lists_its_canonical_directory() + -> Result<(), ErrorCode> { + let config = vec![( + "url".to_owned(), + Setting::Text("https://dav.example/webdav".to_owned()), + )]; + let mut session = Session::from_config(&config)?; + let entries = [ + dav::Found { + href: "/files/notes.md".to_owned(), + collection: false, + length: 12, + etag: None, + }, + dav::Found { + href: "/files/".to_owned(), + collection: true, + length: 0, + etag: None, + }, + ]; + session.record_root(&entries[1..])?; + let expected_url = dav::url(&session.root_href, "/"); + let listed = dav::children(&entries, &expected_url)?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].href, "/files/notes.md"); + + let nested = [ + dav::Found { + href: "/files/documents/child.md".to_owned(), + collection: false, + length: 7, + etag: None, + }, + dav::Found { + href: "/files/documents/".to_owned(), + collection: true, + length: 0, + etag: None, + }, + ]; + let expected_url = dav::url(&session.root_href, "/documents"); + let listed = dav::children(&nested, &expected_url)?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].href, "/files/documents/child.md"); + assert_eq!( + dav::removable(&nested, &expected_url), + Err(ErrorCode::Conflict) + ); + Ok(()) + } + + #[tokio::test] + async fn a_canonical_href_does_not_change_the_authenticated_request_origin() + -> Result<(), ErrorCode> { + let config = vec![ + ( + "url".to_owned(), + Setting::Text("https://dav.example/webdav".to_owned()), + ), + ("username".to_owned(), Setting::Text("user".to_owned())), + ]; + let mut session = Session::from_config(&config)?; + session.record_root(&[dav::Found { + href: "https://other.example/files/".to_owned(), + collection: true, + length: 0, + etag: None, + }])?; + assert_eq!(dav::url(&session.root, "/"), "https://dav.example/webdav"); + assert_eq!( + dav::url(&session.root, "/documents/child.md"), + "https://dav.example/webdav/documents/child.md" + ); + assert_eq!(session.authorization.as_deref(), Some("Basic dXNlcjo=")); + Ok(()) + } + #[tokio::test] async fn a_failed_reopen_discards_the_previous_connection() { SESSION.set(Some(Session { root: "https://dav.example/files/".to_owned(), + root_href: "/files/".to_owned(), authorization: None, })); let result = Webdav::open("webdav".to_owned(), Vec::new()); -- 2.52.0 From 6c493e3238c649b6ce26850b3ea57f9189db0f1c Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 15:40:35 +0900 Subject: [PATCH 06/10] Clarify that remote paths carry literal filesystem names --- wit/filesystem.wit | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wit/filesystem.wit b/wit/filesystem.wit index 4529372..dc8d5c8 100644 --- a/wit/filesystem.wit +++ b/wit/filesystem.wit @@ -81,7 +81,8 @@ interface host { /// 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. +/// Colons and percent signs are literal; paths must never be URL-decoded. +/// Dot segments, backslashes, and controls are invalid. /// Every invocation is bounded by host fuel, memory, time, and cancellation. interface provider { use host.{setting}; -- 2.52.0 From d945b5280f0ebfcdba8a224dd66e844b405bd07d Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 15:46:42 +0900 Subject: [PATCH 07/10] Explain connection permissions and supported transports --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index f1672b9..1974c81 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ It registers one provider, `webdav`, for the `webdav` and `davs` schemes. | Username | Optional account name for HTTP basic authentication | | Password | Optional password, stored as a secret | +In Memona's connection permissions, enter the server's hostname or IP in +**Allowed hosts**, without the URL scheme, port or path. For example, +`http://192.168.1.20:8080/dav/` needs `192.168.1.20`. An empty list blocks every +request, even when the address and credentials are correct. Turn on +**Private network** as well for a server on localhost or a private network; +this switch does not grant a host by itself. Save and reconnect after changing +these permissions. + 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 -- 2.52.0 From 631ea9db65ccb1bce9343c987bfe318d32f2e09d Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 17:46:44 +0900 Subject: [PATCH 08/10] Remove host allowlists from WebDAV plugin setup --- README.md | 11 ++++------- manifest.json | 3 +-- wit/filesystem.wit | 20 ++++++++++---------- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1974c81..9a02c16 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ interface, under the connection's permissions. It registers one provider, `webdav`, for the `webdav` and `davs` schemes. +This package requires a Memona version that supports plugin manifest schema 2. + ## Connecting | Field | Meaning | @@ -15,13 +17,8 @@ It registers one provider, `webdav`, for the `webdav` and `davs` schemes. | Username | Optional account name for HTTP basic authentication | | Password | Optional password, stored as a secret | -In Memona's connection permissions, enter the server's hostname or IP in -**Allowed hosts**, without the URL scheme, port or path. For example, -`http://192.168.1.20:8080/dav/` needs `192.168.1.20`. An empty list blocks every -request, even when the address and credentials are correct. Turn on -**Private network** as well for a server on localhost or a private network; -this switch does not grant a host by itself. Save and reconnect after changing -these permissions. +For localhost or a server on a private network, turn on **Private network** in +the connection settings. Save and reconnect after changing these settings. 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 diff --git a/manifest.json b/manifest.json index e5b5712..e44e761 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "id": "memona/webdav", "name": "WebDAV", "version": "0.1.2", @@ -7,7 +7,6 @@ "description": "Open a WebDAV server as a folder, and let agents work in it.", "license": "MIT", "entry": "plugin.wasm", - "networkHosts": ["*"], "networkPorts": [], "providers": [ { diff --git a/wit/filesystem.wit b/wit/filesystem.wit index dc8d5c8..b5bcdf8 100644 --- a/wit/filesystem.wit +++ b/wit/filesystem.wit @@ -35,10 +35,10 @@ interface host { /// `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 + /// Begins a request to any public HTTP or HTTPS host. Each redirect and + /// DNS result is checked, and DNS is pinned. Private/LAN addresses require + /// the connection's 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; @@ -51,13 +51,13 @@ interface host { /// 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. + /// Opens a socket to any public host on a package-declared port. + /// 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, must be declared. connect: func(host: string, port: u16, tls: bool) -> result; - /// One datagram socket connected to a single granted peer. + /// One datagram socket connected to a single peer. resource udp { /// Sends one datagram of at most 65,507 bytes. send: func(data: list) -> result; @@ -65,7 +65,7 @@ interface host { /// 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 + /// Connects a datagram socket under the same port and private-network rules /// as `connect`, for the few filesystem protocols that still need one. bind: func(host: string, port: u16) -> result; -- 2.52.0 From db4f5999d1e997e2b501d816b7631862a59b47c0 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 19:12:51 +0900 Subject: [PATCH 09/10] Remove obsolete network permission guidance --- README.md | 4 ++-- src/lib.rs | 1 + wit/filesystem.wit | 21 ++++++++++----------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9a02c16..789c5e9 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ This package requires a Memona version that supports plugin manifest schema 2. | Username | Optional account name for HTTP basic authentication | | Password | Optional password, stored as a secret | -For localhost or a server on a private network, turn on **Private network** in -the connection settings. Save and reconnect after changing these settings. +Public, LAN, VPN, link-local and localhost addresses work directly from the +server address; there is no separate network permission setting. 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 diff --git a/src/lib.rs b/src/lib.rs index d408824..abd2df0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -441,6 +441,7 @@ impl Guest for Webdav { } } +#[cfg(not(test))] sdk::export!(Webdav with_types_in sdk); #[cfg(test)] diff --git a/wit/filesystem.wit b/wit/filesystem.wit index b5bcdf8..824db1d 100644 --- a/wit/filesystem.wit +++ b/wit/filesystem.wit @@ -35,11 +35,10 @@ interface host { /// `respond`. An empty result means the body is complete. pull: func(limit: u32) -> result, host-error>; } - /// Begins a request to any public HTTP or HTTPS host. Each redirect and - /// DNS result is checked, and DNS is pinned. Private/LAN addresses require - /// the connection's private-network grant. Cross-origin redirects remove - /// request headers; HTTPS cannot - /// downgrade. The host bounds total bytes per exchange, not buffer size. + /// Begins a request to any HTTP or HTTPS host, including LAN and loopback + /// addresses. Each redirect and DNS result is checked, and DNS is pinned. + /// 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 @@ -51,10 +50,10 @@ interface host { /// Writes at most 1,048,576 bytes and returns how many were accepted. write: func(data: list) -> result; } - /// Opens a socket to any public host on a package-declared port. - /// 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, must be declared. + /// Opens a socket to any host, including LAN and loopback addresses, on a + /// package-declared port. The host resolves and pins DNS and starts TLS + /// when asked. Ports negotiated at runtime, such as an FTP data channel, + /// must be declared. connect: func(host: string, port: u16, tls: bool) -> result; /// One datagram socket connected to a single peer. @@ -65,8 +64,8 @@ interface host { /// 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 port and private-network rules - /// as `connect`, for the few filesystem protocols that still need one. + /// Connects a datagram socket under the same port 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. -- 2.52.0 From e14a4dd1049bd52b5ad81e2eb1748c068d50713b Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 19:31:46 +0900 Subject: [PATCH 10/10] Clarify the grant-free schema 2 contract --- README.md | 5 +++-- wit/filesystem.wit | 17 +++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 789c5e9..0a9b050 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ This package requires a Memona version that supports plugin manifest schema 2. | Username | Optional account name for HTTP basic authentication | | Password | Optional password, stored as a secret | -Public, LAN, VPN, link-local and localhost addresses work directly from the -server address; there is no separate network permission setting. +With the compatible schema 2 Memona version required above, public, LAN, VPN, +link-local and localhost addresses work directly from the server address; +that API has no separate connection network grant. 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 diff --git a/wit/filesystem.wit b/wit/filesystem.wit index 824db1d..624ee9d 100644 --- a/wit/filesystem.wit +++ b/wit/filesystem.wit @@ -36,9 +36,10 @@ interface host { pull: func(limit: u32) -> result, host-error>; } /// Begins a request to any HTTP or HTTPS host, including LAN and loopback - /// addresses. Each redirect and DNS result is checked, and DNS is pinned. - /// Cross-origin redirects remove request headers; HTTPS cannot downgrade. - /// The host bounds total bytes per exchange, not buffer size. + /// addresses. This API has no per-connection network grant. Each redirect + /// and DNS result is checked, and DNS is pinned. 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 @@ -51,9 +52,9 @@ interface host { write: func(data: list) -> result; } /// Opens a socket to any host, including LAN and loopback addresses, on a - /// package-declared port. The host resolves and pins DNS and starts TLS - /// when asked. Ports negotiated at runtime, such as an FTP data channel, - /// must be declared. + /// package-declared port. This API has no per-connection network grant. + /// The host resolves and pins DNS and starts TLS when asked. Ports + /// negotiated at runtime, such as an FTP data channel, must be declared. connect: func(host: string, port: u16, tls: bool) -> result; /// One datagram socket connected to a single peer. @@ -64,8 +65,8 @@ interface host { /// 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 port rules as `connect`, for - /// the few filesystem protocols that still need one. + /// Connects a datagram socket under the same address and port 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. -- 2.52.0