Validate WebDAV connections and match directory responses by path

This commit is contained in:
Danny Kim
2026-09-30 15:26:51 +09:00
parent 3d69750a26
commit b796cd4622
4 changed files with 198 additions and 28 deletions
Generated
+28
View File
@@ -99,9 +99,16 @@ name = "memona-plugin-webdav"
version = "0.1.1" version = "0.1.1"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"tokio",
"wit-bindgen", "wit-bindgen",
] ]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.3.0" version = "0.3.0"
@@ -198,6 +205,27 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.26" version = "1.0.26"
+3
View File
@@ -13,6 +13,9 @@ crate-type = ["cdylib", "rlib"]
wit-bindgen = "=0.62.0" wit-bindgen = "=0.62.0"
quick-xml = { version = "0.37", default-features = false } quick-xml = { version = "0.37", default-features = false }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
[lints.clippy] [lints.clippy]
panic = "deny" panic = "deny"
unwrap_used = "deny" unwrap_used = "deny"
+116 -25
View File
@@ -23,6 +23,9 @@ fn segment(value: &str) -> String {
/// Joins a connection-relative path onto the configured root. /// Joins a connection-relative path onto the configured root.
pub fn url(root: &str, path: &str) -> String { 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(); let mut url = root.trim_end_matches('/').to_owned();
for part in path.split('/').filter(|part| !part.is_empty()) { for part in path.split('/').filter(|part| !part.is_empty()) {
url.push('/'); url.push('/');
@@ -238,23 +241,62 @@ pub fn name_of(href: &str) -> String {
String::from_utf8_lossy(&decoded).into_owned() String::from_utf8_lossy(&decoded).into_owned()
} }
/// The direct children in a Depth 1 reply. The first response describes the /// An href can be an absolute URL or an absolute path (RFC 4918, section 8.3).
/// resource asked about, so it must be a collection before the rest can be fn href_path(href: &str) -> &str {
/// called its children. let path = match href.split_once("://") {
pub fn children(found: &[Found]) -> Result<&[Found], ErrorCode> { Some((_, authority_and_path)) => authority_and_path
match found.split_first() { .find('/')
None => Err(ErrorCode::NotFound), .map(|start| &authority_and_path[start..])
Some((first, rest)) if first.collection => Ok(rest), .unwrap_or("/"),
Some(_) => Err(ErrorCode::InvalidInput), 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<Vec<&'a Found>, 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, /// 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 /// 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. /// WebDAV, so this check is the only thing keeping a whole tree.
pub fn removable(found: &[Found]) -> Result<(), ErrorCode> { pub fn removable(found: &[Found], url: &str) -> Result<(), ErrorCode> {
match children(found) { match children(found, url) {
Ok([]) => Ok(()), Ok(children) if children.is_empty() => Ok(()),
Ok(_) => Err(ErrorCode::Conflict), Ok(_) => Err(ErrorCode::Conflict),
Err(ErrorCode::InvalidInput) => Ok(()), Err(ErrorCode::InvalidInput) => Ok(()),
Err(error) => Err(error), Err(error) => Err(error),
@@ -265,24 +307,24 @@ pub fn removable(found: &[Found]) -> Result<(), ErrorCode> {
mod tests { mod tests {
use super::{ use super::{
Body, Found, basic, children, multistatus, name_of, outcome, range_outcome, Body, Found, basic, children, multistatus, name_of, outcome, range_outcome,
removable, url, removable, resource, url,
}; };
use crate::sdk::ErrorCode; use crate::sdk::ErrorCode;
fn found(collection: bool) -> Found { fn found(href: &str, collection: bool) -> Found {
Found { Found {
href: String::new(), href: href.to_owned(),
collection, collection,
length: 0, length: 0,
etag: None, etag: None,
} }
} }
#[test] #[tokio::test]
fn paths_join_onto_the_root_without_growing_a_boundary() { async fn paths_join_onto_the_root_without_growing_a_boundary() {
assert_eq!( assert_eq!(
url("https://dav.example/files/", "/"), url("https://dav.example/files/", "/"),
"https://dav.example/files" "https://dav.example/files/"
); );
assert_eq!( assert_eq!(
url("https://dav.example/files", "/notes/today.md"), 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] #[test]
fn credentials_encode_the_way_basic_auth_expects() { fn credentials_encode_the_way_basic_auth_expects() {
assert_eq!(basic("user", "pass"), "Basic dXNlcjpwYXNz"); assert_eq!(basic("user", "pass"), "Basic dXNlcjpwYXNz");
@@ -366,21 +448,30 @@ mod tests {
Ok(()) Ok(())
} }
#[test] #[tokio::test]
fn only_an_empty_directory_or_a_file_may_be_removed() { async fn only_an_empty_directory_or_a_file_may_be_removed() {
assert_eq!(removable(&[found(false)]), Ok(())); let url = "https://dav.example/files/";
assert_eq!(removable(&[found(true)]), Ok(())); assert_eq!(removable(&[found("/files/", false)], url), Ok(()));
assert_eq!(removable(&[found("/files/", true)], url), Ok(()));
assert_eq!( assert_eq!(
removable(&[found(true), found(false)]), removable(&[found("/files/", true), found("/files/a.md", false)], url),
Err(ErrorCode::Conflict) Err(ErrorCode::Conflict)
); );
assert_eq!(removable(&[]), Err(ErrorCode::NotFound)); assert_eq!(removable(&[], url), Err(ErrorCode::NotFound));
assert_eq!( assert_eq!(
children(&[found(false)]).err(), children(&[found("/files/", false)], url).err(),
Some(ErrorCode::InvalidInput) Some(ErrorCode::InvalidInput)
); );
assert_eq!( 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) Ok(2)
); );
} }
+51 -3
View File
@@ -34,10 +34,11 @@ struct Webdav;
fn text(setting: Option<Setting>) -> Option<String> { fn text(setting: Option<Setting>) -> Option<String> {
match setting { match setting {
Some(Setting::Text(value)) | Some(Setting::Secret(value)) => { Some(Setting::Text(value)) => {
let value = value.trim().to_owned(); let value = value.trim().to_owned();
(!value.is_empty()).then_some(value) (!value.is_empty()).then_some(value)
} }
Some(Setting::Secret(value)) => Some(value),
Some(Setting::Toggle(_)) | None => None, Some(Setting::Toggle(_)) | None => None,
} }
} }
@@ -225,6 +226,7 @@ impl Guest for Webdav {
_provider_id: String, _provider_id: String,
config: Vec<(String, Setting)>, config: Vec<(String, Setting)>,
) -> Result<Capabilities, ErrorCode> { ) -> Result<Capabilities, ErrorCode> {
SESSION.set(None);
let lookup = |wanted: &str| { let lookup = |wanted: &str| {
config config
.iter() .iter()
@@ -245,6 +247,17 @@ impl Guest for Webdav {
root, root,
authorization, 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 { Ok(Capabilities {
write: true, write: true,
mkdir: true, mkdir: true,
@@ -281,7 +294,8 @@ impl Guest for Webdav {
None => 0, None => 0,
}; };
let found = describe(&path, "1")?; 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() .iter()
.map(|entry| Entry { .map(|entry| Entry {
name: dav::name_of(&entry.href), name: dav::name_of(&entry.href),
@@ -400,7 +414,8 @@ impl Guest for Webdav {
/// first and refused unless it is empty. /// first and refused unless it is empty.
fn remove(target: Locator) -> Result<(), ErrorCode> { fn remove(target: Locator) -> Result<(), ErrorCode> {
let path = route(&target); 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(), &[])?; exchange(host::Method::Delete, &path, Vec::new(), &[])?;
Ok(()) Ok(())
} }
@@ -411,3 +426,36 @@ impl Guest for Webdav {
} }
sdk::export!(Webdav with_types_in sdk); 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));
}
}