509 lines
16 KiB
Rust
509 lines
16 KiB
Rust
//! 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<bool, ErrorCode> {
|
|
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<u8>,
|
|
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<Vec<u8>, ErrorCode>,
|
|
) -> Result<Vec<u8>, 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<String>,
|
|
}
|
|
|
|
/// 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<Vec<Found>, 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<Found> = 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<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,
|
|
/// 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::<u8>::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::<u8>::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#"<?xml version="1.0"?>
|
|
<D:multistatus xmlns:D="DAV:">
|
|
<D:response>
|
|
<D:href>/files/notes/</D:href>
|
|
<D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype>
|
|
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>
|
|
</D:response>
|
|
<D:response>
|
|
<D:href>/files/notes/a%20b.md</D:href>
|
|
<D:propstat><D:prop>
|
|
<D:resourcetype/>
|
|
<D:getcontentlength>42</D:getcontentlength>
|
|
<D:getetag>"v1"</D:getetag>
|
|
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>
|
|
</D:response>
|
|
</D:multistatus>"#;
|
|
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(())
|
|
}
|
|
}
|