From b796cd462280d75b917ce79cd6a6210e31c0a82f Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 15:26:51 +0900 Subject: [PATCH] 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)); + } +}