//! 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 { 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('/'); 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() } /// 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], 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), } } #[cfg(test)] mod tests { use super::{ Body, Found, basic, children, multistatus, name_of, outcome, range_outcome, removable, resource, url, }; use crate::sdk::ErrorCode; fn found(href: &str, collection: bool) -> Found { Found { href: href.to_owned(), collection, length: 0, etag: None, } } #[tokio::test] async 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/" ); } #[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"); 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(()) } #[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("/files/", true), found("/files/a.md", false)], url), Err(ErrorCode::Conflict) ); assert_eq!(removable(&[], url), Err(ErrorCode::NotFound)); assert_eq!( children(&[found("/files/", false)], url).err(), Some(ErrorCode::InvalidInput) ); assert_eq!( children( &[ found("/files/", true), found("/files/a.md", false), found("/files/notes/", true) ], url, ) .map(|children| children.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(()) } }