- Use :: separator in scoped keyring keys to prevent ambiguity with usernames containing dots (e.g. com.onyx.webdav.host::user) - Auto-migrate legacy credentials to scoped format on load, removing old unscoped entries after successful migration - Add 10MB response size limit on PROPFIND to prevent memory exhaustion from malicious servers (checks Content-Length header + actual body) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
754 lines
26 KiB
Rust
754 lines
26 KiB
Rust
use reqwest::Client;
|
|
use zeroize::Zeroizing;
|
|
use std::time::Duration;
|
|
use crate::error::{Error, Result};
|
|
|
|
/// Hard timeout for any WebDAV network operation.
|
|
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
/// Information about a file on the remote WebDAV server.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RemoteFileInfo {
|
|
pub path: String,
|
|
pub is_dir: bool,
|
|
pub content_length: u64,
|
|
pub last_modified: Option<String>,
|
|
}
|
|
|
|
/// WebDAV client wrapping reqwest with basic auth. Credentials are zeroized on drop.
|
|
pub struct WebDavClient {
|
|
_client: Client,
|
|
_base_url: String,
|
|
_username: Zeroizing<String>,
|
|
_password: Zeroizing<String>,
|
|
}
|
|
|
|
impl WebDavClient {
|
|
/// Create a new WebDAV client. Rejects non-HTTPS URLs to prevent sending credentials in plaintext.
|
|
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self> {
|
|
if !base_url.starts_with("https://") {
|
|
return Err(Error::WebDav("Refusing non-HTTPS URL: credentials would be sent in plaintext".into()));
|
|
}
|
|
Ok(Self::new_unchecked(base_url, username, password))
|
|
}
|
|
|
|
fn new_unchecked(base_url: &str, username: &str, password: &str) -> Self {
|
|
let base_url = base_url.trim_end_matches('/').to_string();
|
|
Self {
|
|
_client: Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.connect_timeout(Duration::from_secs(10))
|
|
.build()
|
|
.unwrap_or_else(|_| Client::new()),
|
|
_base_url: base_url,
|
|
_username: Zeroizing::new(username.to_string()),
|
|
_password: Zeroizing::new(password.to_string()),
|
|
}
|
|
}
|
|
|
|
fn full_url(&self, path: &str) -> String {
|
|
let path = path.trim_start_matches('/');
|
|
if path.is_empty() {
|
|
self._base_url.clone()
|
|
} else {
|
|
// Percent-encode path segments while preserving '/'
|
|
let encoded: String = path
|
|
.split('/')
|
|
.map(percent_encode)
|
|
.collect::<Vec<_>>()
|
|
.join("/");
|
|
format!("{}/{}", self._base_url, encoded)
|
|
}
|
|
}
|
|
|
|
/// Test connection by issuing a PROPFIND depth 0 on the root.
|
|
pub async fn test_connection(&self) -> Result<()> {
|
|
let resp = self._client
|
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self._base_url)
|
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
|
.header("Depth", "0")
|
|
.header("Content-Type", "application/xml")
|
|
.body(PROPFIND_BODY)
|
|
.send()
|
|
.await?;
|
|
|
|
let status = resp.status().as_u16();
|
|
if status == 207 || status == 200 {
|
|
Ok(())
|
|
} else if status == 401 || status == 403 {
|
|
Err(Error::Credential("Authentication failed".to_string()))
|
|
} else {
|
|
Err(Error::WebDav(format!("Unexpected status {}", status)))
|
|
}
|
|
}
|
|
|
|
/// List files at a given path using PROPFIND depth 1.
|
|
pub async fn list_files(&self, path: &str) -> Result<Vec<RemoteFileInfo>> {
|
|
let url = self.full_url(path);
|
|
let resp = self._client
|
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
|
.header("Depth", "1")
|
|
.header("Content-Type", "application/xml")
|
|
.body(PROPFIND_BODY)
|
|
.send()
|
|
.await?;
|
|
|
|
let status = resp.status().as_u16();
|
|
if status != 207 {
|
|
return Err(Error::WebDav(format!("PROPFIND failed with status {}", status)));
|
|
}
|
|
|
|
// Reject oversized responses to prevent memory exhaustion from malicious servers
|
|
const MAX_PROPFIND_BYTES: u64 = 10 * 1024 * 1024;
|
|
if resp.content_length().unwrap_or(0) > MAX_PROPFIND_BYTES {
|
|
return Err(Error::WebDav("PROPFIND response too large (>10MB)".into()));
|
|
}
|
|
let bytes = resp.bytes().await?;
|
|
if bytes.len() as u64 > MAX_PROPFIND_BYTES {
|
|
return Err(Error::WebDav("PROPFIND response too large (>10MB)".into()));
|
|
}
|
|
let body = String::from_utf8_lossy(&bytes);
|
|
parse_propfind_response(&body, &self._base_url, path)
|
|
}
|
|
|
|
/// Download a file's contents.
|
|
pub async fn get_file(&self, path: &str) -> Result<Vec<u8>> {
|
|
let url = self.full_url(path);
|
|
let resp = self._client
|
|
.get(&url)
|
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
|
.send()
|
|
.await?;
|
|
|
|
let status = resp.status().as_u16();
|
|
if status == 404 {
|
|
return Err(Error::NotFound(format!("Remote file not found: {}", path)));
|
|
}
|
|
if status != 200 {
|
|
return Err(Error::WebDav(format!("GET failed with status {}", status)));
|
|
}
|
|
|
|
Ok(resp.bytes().await?.to_vec())
|
|
}
|
|
|
|
/// Upload a file.
|
|
pub async fn put_file(&self, path: &str, content: Vec<u8>) -> Result<()> {
|
|
let url = self.full_url(path);
|
|
let resp = self._client
|
|
.put(&url)
|
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
|
.body(content)
|
|
.send()
|
|
.await?;
|
|
|
|
let status = resp.status().as_u16();
|
|
if !(200..=299).contains(&status) {
|
|
return Err(Error::WebDav(format!("PUT failed with status {}", status)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a remote file.
|
|
pub async fn delete_file(&self, path: &str) -> Result<()> {
|
|
let url = self.full_url(path);
|
|
let resp = self._client
|
|
.delete(&url)
|
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
|
.send()
|
|
.await?;
|
|
|
|
let status = resp.status().as_u16();
|
|
if status == 404 {
|
|
return Ok(()); // Already gone
|
|
}
|
|
if !(200..=299).contains(&status) {
|
|
return Err(Error::WebDav(format!("DELETE failed with status {}", status)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Create a directory via MKCOL.
|
|
pub async fn create_dir(&self, path: &str) -> Result<()> {
|
|
let url = self.full_url(path);
|
|
let resp = self._client
|
|
.request(reqwest::Method::from_bytes(b"MKCOL").unwrap(), &url)
|
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
|
.send()
|
|
.await?;
|
|
|
|
let status = resp.status().as_u16();
|
|
if status == 405 {
|
|
return Ok(()); // Already exists
|
|
}
|
|
if !(200..=299).contains(&status) {
|
|
return Err(Error::WebDav(format!("MKCOL failed with status {}", status)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Ensure a directory exists, creating it and parents as needed.
|
|
pub async fn ensure_dir(&self, path: &str) -> Result<()> {
|
|
let parts: Vec<&str> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();
|
|
let mut current = String::new();
|
|
for part in parts {
|
|
current = if current.is_empty() {
|
|
part.to_string()
|
|
} else {
|
|
format!("{}/{}", current, part)
|
|
};
|
|
self.create_dir(¤t).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
const PROPFIND_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
|
<D:propfind xmlns:D="DAV:">
|
|
<D:prop>
|
|
<D:resourcetype/>
|
|
<D:getcontentlength/>
|
|
<D:getlastmodified/>
|
|
</D:prop>
|
|
</D:propfind>"#;
|
|
|
|
/// Percent-encode a single path segment (not the whole path).
|
|
fn percent_encode(segment: &str) -> String {
|
|
let mut result = String::new();
|
|
for byte in segment.bytes() {
|
|
match byte {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
result.push(byte as char);
|
|
}
|
|
_ => {
|
|
result.push_str(&format!("%{:02X}", byte));
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Percent-decode a string.
|
|
fn percent_decode(s: &str) -> String {
|
|
let mut result = Vec::new();
|
|
let bytes = s.as_bytes();
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
|
if let Ok(val) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
|
result.push(val);
|
|
i += 3;
|
|
continue;
|
|
}
|
|
}
|
|
result.push(bytes[i]);
|
|
i += 1;
|
|
}
|
|
String::from_utf8_lossy(&result).to_string()
|
|
}
|
|
|
|
/// Parse a PROPFIND multistatus XML response into RemoteFileInfo entries.
|
|
/// Handles namespace prefix variations (d:, D:, no prefix).
|
|
fn parse_propfind_response(xml: &str, base_url: &str, request_path: &str) -> Result<Vec<RemoteFileInfo>> {
|
|
use quick_xml::events::Event;
|
|
use quick_xml::Reader;
|
|
|
|
let mut reader = Reader::from_str(xml);
|
|
let mut results = Vec::new();
|
|
|
|
// State machine for parsing
|
|
let mut in_response = false;
|
|
let mut in_propstat = false;
|
|
let mut in_prop = false;
|
|
let mut current_href: Option<String> = None;
|
|
let mut current_is_dir = false;
|
|
let mut current_content_length: u64 = 0;
|
|
let mut current_last_modified: Option<String> = None;
|
|
let mut reading_href = false;
|
|
let mut reading_content_length = false;
|
|
let mut reading_last_modified = false;
|
|
let mut in_resourcetype = false;
|
|
|
|
loop {
|
|
match reader.read_event() {
|
|
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
|
|
let name_bytes = e.name().as_ref().to_vec();
|
|
let local = local_name(&name_bytes);
|
|
match local {
|
|
"response" => {
|
|
in_response = true;
|
|
current_href = None;
|
|
current_is_dir = false;
|
|
current_content_length = 0;
|
|
current_last_modified = None;
|
|
}
|
|
"propstat" => in_propstat = true,
|
|
"prop" if in_propstat => in_prop = true,
|
|
"href" if in_response => reading_href = true,
|
|
"resourcetype" if in_prop => in_resourcetype = true,
|
|
"collection" if in_resourcetype => current_is_dir = true,
|
|
"getcontentlength" if in_prop => reading_content_length = true,
|
|
"getlastmodified" if in_prop => reading_last_modified = true,
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(Event::End(ref e)) => {
|
|
let name_bytes = e.name().as_ref().to_vec();
|
|
let local = local_name(&name_bytes);
|
|
match local {
|
|
"response" => {
|
|
if let Some(href) = current_href.take() {
|
|
let path = extract_relative_path(&href, base_url, request_path);
|
|
if !path.is_empty() {
|
|
results.push(RemoteFileInfo {
|
|
path,
|
|
is_dir: current_is_dir,
|
|
content_length: current_content_length,
|
|
last_modified: current_last_modified.take(),
|
|
});
|
|
}
|
|
}
|
|
in_response = false;
|
|
}
|
|
"propstat" => in_propstat = false,
|
|
"prop" => in_prop = false,
|
|
"resourcetype" => in_resourcetype = false,
|
|
"href" => reading_href = false,
|
|
"getcontentlength" => reading_content_length = false,
|
|
"getlastmodified" => reading_last_modified = false,
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(Event::Text(ref e)) => {
|
|
if let Ok(text) = e.unescape() {
|
|
let text = text.to_string();
|
|
if reading_href {
|
|
current_href = Some(text);
|
|
} else if reading_content_length {
|
|
current_content_length = text.trim().parse().unwrap_or(0);
|
|
} else if reading_last_modified {
|
|
current_last_modified = Some(text);
|
|
}
|
|
}
|
|
}
|
|
Ok(Event::Eof) => break,
|
|
Err(e) => return Err(Error::WebDav(format!("XML parse error: {}", e))),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Get local name from a potentially namespaced XML tag name.
|
|
fn local_name(name: &[u8]) -> &str {
|
|
let s = std::str::from_utf8(name).unwrap_or("");
|
|
// Handle both "D:href" and "href" and "{DAV:}href" forms
|
|
if let Some(pos) = s.rfind(':') {
|
|
&s[pos + 1..]
|
|
} else if let Some(pos) = s.rfind('}') {
|
|
&s[pos + 1..]
|
|
} else {
|
|
s
|
|
}
|
|
}
|
|
|
|
/// Extract a relative path from an href, stripping the base URL prefix and the request path.
|
|
fn extract_relative_path(href: &str, base_url: &str, request_path: &str) -> String {
|
|
let decoded = percent_decode(href);
|
|
// Strip scheme + host if present
|
|
let path = if let Some(pos) = decoded.find("://") {
|
|
let after_scheme = &decoded[pos + 3..];
|
|
if let Some(slash) = after_scheme.find('/') {
|
|
&after_scheme[slash..]
|
|
} else {
|
|
""
|
|
}
|
|
} else {
|
|
decoded.as_str()
|
|
};
|
|
|
|
// Extract the base path from base_url
|
|
let base_path = if let Some(pos) = base_url.find("://") {
|
|
let after_scheme = &base_url[pos + 3..];
|
|
if let Some(slash) = after_scheme.find('/') {
|
|
&after_scheme[slash..]
|
|
} else {
|
|
""
|
|
}
|
|
} else {
|
|
""
|
|
};
|
|
|
|
let mut relative = path.to_string();
|
|
// Strip base path prefix
|
|
if !base_path.is_empty() {
|
|
let bp = base_path.trim_end_matches('/');
|
|
if let Some(stripped) = relative.strip_prefix(bp) {
|
|
relative = stripped.to_string();
|
|
}
|
|
}
|
|
|
|
// Strip request path prefix
|
|
let req = request_path.trim_matches('/');
|
|
if !req.is_empty() {
|
|
let prefixed = format!("/{}", req);
|
|
if let Some(stripped) = relative.strip_prefix(&prefixed) {
|
|
relative = stripped.to_string();
|
|
}
|
|
}
|
|
|
|
// Clean up leading/trailing slashes
|
|
let relative = relative.trim_matches('/').to_string();
|
|
relative
|
|
}
|
|
|
|
// --- Credential Storage ---
|
|
|
|
#[cfg(feature = "keyring-storage")]
|
|
/// Store WebDAV credentials in the platform keychain. Password is scoped by domain+username
|
|
/// to prevent collisions when multiple accounts exist on the same server.
|
|
pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result<()> {
|
|
let service = format!("com.onyx.webdav.{}", domain);
|
|
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, username);
|
|
|
|
let user_entry = keyring::Entry::new(&service, "username")
|
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
|
user_entry.set_password(username)
|
|
.map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?;
|
|
|
|
let pass_entry = keyring::Entry::new(&scoped_service, "password")
|
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
|
pass_entry.set_password(password)
|
|
.map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?;
|
|
|
|
// Clean up legacy unscoped password entry if present
|
|
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
|
|
let _ = legacy.delete_credential();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(feature = "keyring-storage"))]
|
|
/// Store WebDAV credentials (not available without keyring-storage feature).
|
|
pub fn store_credentials(_domain: &str, _username: &str, _password: &str) -> Result<()> {
|
|
Err(Error::Credential("Credential storage not available on this platform".into()))
|
|
}
|
|
|
|
#[cfg(feature = "keyring-storage")]
|
|
/// Load WebDAV credentials from the platform keychain, falling back to env vars.
|
|
pub fn load_credentials(domain: &str) -> Result<(Zeroizing<String>, Zeroizing<String>)> {
|
|
let service = format!("com.onyx.webdav.{}", domain);
|
|
|
|
let user_entry = keyring::Entry::new(&service, "username")
|
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
|
|
|
if let Ok(user) = user_entry.get_password() {
|
|
// Try scoped password key first (domain+username), fall back to legacy unscoped key
|
|
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, user);
|
|
let found = keyring::Entry::new(&scoped_service, "password")
|
|
.ok()
|
|
.and_then(|e| e.get_password().ok())
|
|
.map(|p| (p, false))
|
|
.or_else(|| {
|
|
// Migration fallback: try legacy unscoped password entry
|
|
keyring::Entry::new(&service, "password")
|
|
.ok()
|
|
.and_then(|e| e.get_password().ok())
|
|
.map(|p| (p, true))
|
|
});
|
|
|
|
if let Some((pass, needs_migration)) = found {
|
|
// Auto-migrate legacy credentials to scoped format
|
|
if needs_migration {
|
|
if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") {
|
|
let _ = entry.set_password(&pass);
|
|
}
|
|
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
|
|
let _ = legacy.delete_credential();
|
|
}
|
|
}
|
|
return Ok((Zeroizing::new(user), Zeroizing::new(pass)));
|
|
}
|
|
}
|
|
|
|
// Fallback to env vars for headless/CI environments
|
|
if let (Ok(user), Ok(pass)) = (
|
|
std::env::var("ONYX_WEBDAV_USER"),
|
|
std::env::var("ONYX_WEBDAV_PASS"),
|
|
) {
|
|
log::warn!("Using environment variables for WebDAV credentials — prefer keyring for better security");
|
|
return Ok((Zeroizing::new(user), Zeroizing::new(pass)));
|
|
}
|
|
|
|
Err(Error::Credential(format!(
|
|
"No credentials found for '{}'. Run setup or configure environment variables.",
|
|
domain
|
|
)))
|
|
}
|
|
|
|
#[cfg(not(feature = "keyring-storage"))]
|
|
/// Load WebDAV credentials from env vars only (keyring not available).
|
|
pub fn load_credentials(domain: &str) -> Result<(Zeroizing<String>, Zeroizing<String>)> {
|
|
if let (Ok(user), Ok(pass)) = (
|
|
std::env::var("ONYX_WEBDAV_USER"),
|
|
std::env::var("ONYX_WEBDAV_PASS"),
|
|
) {
|
|
log::warn!("Using environment variables for WebDAV credentials — these are visible to other processes on this system");
|
|
return Ok((Zeroizing::new(user), Zeroizing::new(pass)));
|
|
}
|
|
|
|
Err(Error::Credential(format!(
|
|
"No credentials found for '{}'. Configure environment variables.",
|
|
domain
|
|
)))
|
|
}
|
|
|
|
#[cfg(feature = "keyring-storage")]
|
|
/// Delete WebDAV credentials from the platform keychain.
|
|
pub fn delete_credentials(domain: &str) -> Result<()> {
|
|
let service = format!("com.onyx.webdav.{}", domain);
|
|
|
|
// Load username first so we can delete the scoped password entry
|
|
let username = keyring::Entry::new(&service, "username")
|
|
.ok()
|
|
.and_then(|e| e.get_password().ok());
|
|
|
|
if let Some(user) = &username {
|
|
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, user);
|
|
if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") {
|
|
let _ = entry.delete_credential();
|
|
}
|
|
}
|
|
|
|
// Clean up legacy unscoped password and username entries
|
|
if let Ok(entry) = keyring::Entry::new(&service, "password") {
|
|
let _ = entry.delete_credential();
|
|
}
|
|
if let Ok(entry) = keyring::Entry::new(&service, "username") {
|
|
let _ = entry.delete_credential();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(feature = "keyring-storage"))]
|
|
/// Delete WebDAV credentials (no-op without keyring-storage feature).
|
|
pub fn delete_credentials(_domain: &str) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// --- URL encoding tests ---
|
|
|
|
#[test]
|
|
fn test_percent_encode_simple() {
|
|
assert_eq!(percent_encode("hello"), "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn test_percent_encode_spaces() {
|
|
assert_eq!(percent_encode("Buy groceries"), "Buy%20groceries");
|
|
}
|
|
|
|
#[test]
|
|
fn test_percent_encode_special_chars() {
|
|
assert_eq!(percent_encode("task (1)"), "task%20%281%29");
|
|
}
|
|
|
|
#[test]
|
|
fn test_percent_decode_roundtrip() {
|
|
let original = "Buy groceries (urgent)";
|
|
let encoded = percent_encode(original);
|
|
let decoded = percent_decode(&encoded);
|
|
assert_eq!(decoded, original);
|
|
}
|
|
|
|
// --- PROPFIND XML parsing tests ---
|
|
|
|
#[test]
|
|
fn test_parse_propfind_with_d_prefix() {
|
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>/remote/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
<d:getcontentlength>0</d:getcontentlength>
|
|
</d:prop>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/remote/My%20Tasks/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
<d:getcontentlength>0</d:getcontentlength>
|
|
</d:prop>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/remote/My%20Tasks/Buy%20groceries.md</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype/>
|
|
<d:getcontentlength>150</d:getcontentlength>
|
|
<d:getlastmodified>Mon, 01 Jan 2026 00:00:00 GMT</d:getlastmodified>
|
|
</d:prop>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
|
|
let results = parse_propfind_response(xml, "http://example.com/remote", "").unwrap();
|
|
assert_eq!(results.len(), 2); // Root directory itself is empty path -> skipped
|
|
assert_eq!(results[0].path, "My Tasks");
|
|
assert!(results[0].is_dir);
|
|
assert_eq!(results[1].path, "My Tasks/Buy groceries.md");
|
|
assert!(!results[1].is_dir);
|
|
assert_eq!(results[1].content_length, 150);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_propfind_with_uppercase_d_prefix() {
|
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
|
<D:multistatus xmlns:D="DAV:">
|
|
<D:response>
|
|
<D:href>/dav/</D:href>
|
|
<D:propstat>
|
|
<D:prop>
|
|
<D:resourcetype><D:collection/></D:resourcetype>
|
|
</D:prop>
|
|
</D:propstat>
|
|
</D:response>
|
|
<D:response>
|
|
<D:href>/dav/notes.md</D:href>
|
|
<D:propstat>
|
|
<D:prop>
|
|
<D:resourcetype/>
|
|
<D:getcontentlength>42</D:getcontentlength>
|
|
</D:prop>
|
|
</D:propstat>
|
|
</D:response>
|
|
</D:multistatus>"#;
|
|
|
|
let results = parse_propfind_response(xml, "http://example.com/dav", "").unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].path, "notes.md");
|
|
assert!(!results[0].is_dir);
|
|
assert_eq!(results[0].content_length, 42);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_propfind_no_prefix() {
|
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
|
<multistatus xmlns="DAV:">
|
|
<response>
|
|
<href>/files/</href>
|
|
<propstat>
|
|
<prop>
|
|
<resourcetype><collection/></resourcetype>
|
|
</prop>
|
|
</propstat>
|
|
</response>
|
|
<response>
|
|
<href>/files/test.md</href>
|
|
<propstat>
|
|
<prop>
|
|
<resourcetype/>
|
|
<getcontentlength>100</getcontentlength>
|
|
<getlastmodified>Tue, 15 Mar 2026 10:30:00 GMT</getlastmodified>
|
|
</prop>
|
|
</propstat>
|
|
</response>
|
|
</multistatus>"#;
|
|
|
|
let results = parse_propfind_response(xml, "http://example.com/files", "").unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].path, "test.md");
|
|
assert_eq!(results[0].last_modified.as_deref(), Some("Tue, 15 Mar 2026 10:30:00 GMT"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_propfind_with_subpath() {
|
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>/remote/My%20Tasks/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
</d:prop>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/remote/My%20Tasks/task1.md</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype/>
|
|
<d:getcontentlength>50</d:getcontentlength>
|
|
</d:prop>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
|
|
let results = parse_propfind_response(xml, "http://example.com/remote", "My Tasks").unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].path, "task1.md");
|
|
}
|
|
|
|
// --- WebDavClient URL building ---
|
|
|
|
#[test]
|
|
fn test_new_rejects_http() {
|
|
let result = WebDavClient::new("http://example.com/dav", "user", "pass");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_accepts_https() {
|
|
let result = WebDavClient::new("https://example.com/dav", "user", "pass");
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_full_url_building() {
|
|
let client = WebDavClient::new_unchecked("http://example.com/dav/", "user", "pass");
|
|
assert_eq!(client.full_url(""), "http://example.com/dav");
|
|
assert_eq!(client.full_url("file.md"), "http://example.com/dav/file.md");
|
|
assert_eq!(client.full_url("My Tasks/Buy groceries.md"), "http://example.com/dav/My%20Tasks/Buy%20groceries.md");
|
|
}
|
|
|
|
#[test]
|
|
fn test_full_url_strips_leading_slash() {
|
|
let client = WebDavClient::new_unchecked("http://example.com/dav", "user", "pass");
|
|
assert_eq!(client.full_url("/file.md"), "http://example.com/dav/file.md");
|
|
}
|
|
|
|
// --- extract_relative_path ---
|
|
|
|
#[test]
|
|
fn test_extract_relative_path_full_url_href() {
|
|
let path = extract_relative_path(
|
|
"http://example.com/dav/My%20Tasks/file.md",
|
|
"http://example.com/dav",
|
|
"",
|
|
);
|
|
assert_eq!(path, "My Tasks/file.md");
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_relative_path_absolute_href() {
|
|
let path = extract_relative_path(
|
|
"/dav/Work/meeting.md",
|
|
"http://example.com/dav",
|
|
"",
|
|
);
|
|
assert_eq!(path, "Work/meeting.md");
|
|
}
|
|
}
|