feat(webdav): implement RFC 4918 dead property store

Add an in-memory DeadPropertyStore backed by RwLock<HashMap> that
stores arbitrary client-supplied XML properties per resource path.
Wire it into AppState so PROPPATCH can persist dead props and PROPFIND
can retrieve them across requests.
This commit is contained in:
M.Schmidt
2026-06-26 21:19:03 +02:00
parent 5a001c4120
commit b5afa1a855
5 changed files with 532 additions and 95 deletions
+244 -57
View File
@@ -96,6 +96,13 @@ pub struct PropValue {
pub value: Option<String>, pub value: Option<String>,
} }
/// A single PROPPATCH operation (preserves document order per RFC 4918 §9.2).
#[derive(Debug, Clone)]
pub enum PropPatchOp {
Set(PropValue),
Remove(QualifiedName),
}
/// WebDAV lock information /// WebDAV lock information
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LockInfo { pub struct LockInfo {
@@ -188,13 +195,36 @@ impl WebDavAdapter {
) { ) {
for attr in e.attributes().flatten() { for attr in e.attributes().flatten() {
let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or(""); let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
if let Some(prefix) = key.strip_prefix("xmlns:") { let prefix = key
let uri = attr.unescape_value().unwrap_or_default().to_string(); .strip_prefix("xmlns:")
ns_map.insert(prefix.to_string(), uri); .map(str::to_owned)
.or_else(|| (key == "xmlns").then(String::new));
if let Some(prefix) = prefix {
ns_map.insert(
prefix,
attr.unescape_value().unwrap_or_default().to_string(),
);
} }
} }
} }
/// Reject `xmlns:prefix=""` declarations — binding a prefix to an empty URI
/// is forbidden by the XML Namespaces 1.0 spec (RFC 4918 §8.1 requires 400).
fn check_ns_decls_valid(e: &BytesStart) -> Result<()> {
for attr in e.attributes().flatten() {
let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
if key.starts_with("xmlns:") {
let uri = attr.unescape_value().unwrap_or_default();
if uri.is_empty() {
return Err(WebDavError::ParseError(
"Invalid namespace declaration: prefix bound to empty URI".to_string(),
));
}
}
}
Ok(())
}
/// Resolve a prefixed element name (e.g. `D:resourcetype`) to a /// Resolve a prefixed element name (e.g. `D:resourcetype`) to a
/// `QualifiedName` using the accumulated namespace declarations. /// `QualifiedName` using the accumulated namespace declarations.
pub fn resolve_name( pub fn resolve_name(
@@ -208,6 +238,11 @@ impl WebDavAdapter {
return QualifiedName::new(uri.clone(), local.to_string()); return QualifiedName::new(uri.clone(), local.to_string());
} }
} }
// No prefix: check for a default namespace (xmlns="...").
// An empty string means xmlns="" — null namespace override, which is valid.
if let Some(default_ns) = ns_map.get("") {
return QualifiedName::new(default_ns.clone(), name_str.to_string());
}
// Fallback: no prefix or unknown prefix → use legacy extraction // Fallback: no prefix or unknown prefix → use legacy extraction
QualifiedName::new( QualifiedName::new(
Self::extract_namespace(name_str), Self::extract_namespace(name_str),
@@ -222,6 +257,7 @@ impl WebDavAdapter {
let mut buffer = Vec::new(); let mut buffer = Vec::new();
let mut in_propfind = false; let mut in_propfind = false;
let mut saw_propfind_close = false;
let mut in_prop = false; let mut in_prop = false;
let mut in_allprop = false; let mut in_allprop = false;
let mut in_propname = false; let mut in_propname = false;
@@ -232,6 +268,7 @@ impl WebDavAdapter {
match xml_reader.read_event_into(&mut buffer) { match xml_reader.read_event_into(&mut buffer) {
Ok(Event::Start(ref e)) => { Ok(Event::Start(ref e)) => {
Self::collect_ns_decls(e, &mut ns_map); Self::collect_ns_decls(e, &mut ns_map);
Self::check_ns_decls_valid(e)?;
let name = e.name(); let name = e.name();
let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or("");
@@ -258,6 +295,7 @@ impl WebDavAdapter {
if name_str == "propfind" || name_str.ends_with(":propfind") { if name_str == "propfind" || name_str.ends_with(":propfind") {
in_propfind = false; in_propfind = false;
saw_propfind_close = true;
} else if name_str == "prop" || name_str.ends_with(":prop") { } else if name_str == "prop" || name_str.ends_with(":prop") {
in_prop = false; in_prop = false;
} else if name_str == "allprop" || name_str.ends_with(":allprop") { } else if name_str == "allprop" || name_str.ends_with(":allprop") {
@@ -268,6 +306,7 @@ impl WebDavAdapter {
} }
Ok(Event::Empty(ref e)) => { Ok(Event::Empty(ref e)) => {
Self::collect_ns_decls(e, &mut ns_map); Self::collect_ns_decls(e, &mut ns_map);
Self::check_ns_decls_valid(e)?;
let name = e.name(); let name = e.name();
let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or("");
@@ -290,6 +329,16 @@ impl WebDavAdapter {
buffer.clear(); buffer.clear();
} }
// RFC 4918 §8.1: non-well-formed XML MUST produce 400. quick-xml is
// lenient about EOF-inside-element (no XmlError on unclosed tags), so
// check explicitly: body must contain a complete <propfind>…</propfind>.
if !saw_propfind_close {
return Err(WebDavError::ParseError(
"PROPFIND body is not well-formed XML: missing or unclosed <propfind> element"
.to_string(),
));
}
let prop_find_type = if in_allprop { let prop_find_type = if in_allprop {
PropFindType::AllProp PropFindType::AllProp
} else if in_propname { } else if in_propname {
@@ -301,7 +350,7 @@ impl WebDavAdapter {
Ok(PropFindRequest { prop_find_type }) Ok(PropFindRequest { prop_find_type })
} }
fn folder_prop_is_known(prop: &QualifiedName) -> bool { fn prop_is_known(prop: &QualifiedName) -> bool {
prop.namespace == "DAV:" prop.namespace == "DAV:"
&& matches!( && matches!(
prop.name.as_str(), prop.name.as_str(),
@@ -315,18 +364,22 @@ impl WebDavAdapter {
) )
} }
fn file_prop_is_known(prop: &QualifiedName) -> bool { /// Write a single qualified name as an empty XML element with proper namespace declaration.
prop.namespace == "DAV:" ///
&& matches!( /// DAV: props use the `D:` prefix (already declared on the root element).
prop.name.as_str(), /// All other namespaces get a local `xmlns:X` declaration on the element itself.
"resourcetype" fn write_qname_empty<W: Write>(xml_writer: &mut Writer<W>, prop: &QualifiedName) -> Result<()> {
| "displayname" if prop.namespace.is_empty() {
| "getcontenttype" xml_writer.write_event(Event::Empty(BytesStart::new(prop.name.as_str())))?;
| "getcontentlength" } else if prop.namespace == "DAV:" {
| "creationdate" xml_writer.write_event(Event::Empty(BytesStart::new(format!("D:{}", prop.name))))?;
| "getlastmodified" } else {
| "getetag" let tag = format!("X:{}", prop.name);
) let mut start = BytesStart::new(tag.as_str());
start.push_attribute(("xmlns:X", prop.namespace.as_str()));
xml_writer.write_event(Event::Empty(start))?;
}
Ok(())
} }
/// Write a 404 propstat block for unknown properties (RFC 4918 §9.2). /// Write a 404 propstat block for unknown properties (RFC 4918 §9.2).
@@ -340,15 +393,7 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
for prop in unknown { for prop in unknown {
if prop.namespace == "DAV:" { Self::write_qname_empty(xml_writer, prop)?;
xml_writer
.write_event(Event::Empty(BytesStart::new(format!("D:{}", prop.name))))?;
} else {
xml_writer.write_event(Event::Empty(BytesStart::new(format!(
"{}:{}",
prop.namespace, prop.name
))))?;
}
} }
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
@@ -358,12 +403,64 @@ impl WebDavAdapter {
Ok(()) Ok(())
} }
/// Write a dead-property propstat block (RFC 4918 §4.2).
///
/// Written AFTER the live-property propstats inside a `<D:response>`.
/// Only emitted when `dead_props` is non-empty.
fn write_dead_props_propstat<W: Write>(
xml_writer: &mut Writer<W>,
dead_props: &[(QualifiedName, Option<String>)],
) -> Result<()> {
if dead_props.is_empty() {
return Ok(());
}
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
for (name, value) in dead_props {
let tag = if name.namespace.is_empty() {
name.name.clone()
} else {
format!("X:{}", name.name)
};
let mut start = BytesStart::new(tag.as_str());
if !name.namespace.is_empty() {
start.push_attribute(("xmlns:X", name.namespace.as_str()));
}
match value {
Some(v) if !v.is_empty() => {
xml_writer.write_event(Event::Start(start))?;
xml_writer.write_event(Event::Text(BytesText::new(v)))?;
xml_writer.write_event(Event::End(BytesEnd::new(tag.as_str())))?;
}
_ => {
xml_writer.write_event(Event::Empty(start))?;
}
}
}
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
Ok(())
}
/// Write folder properties as a response /// Write folder properties as a response
fn write_folder_response<W: Write>( fn write_folder_response<W: Write>(
xml_writer: &mut Writer<W>, xml_writer: &mut Writer<W>,
folder: &FolderDto, folder: &FolderDto,
request: &PropFindRequest, request: &PropFindRequest,
href: &str, href: &str,
) -> Result<()> {
Self::write_folder_response_with_dead_props(xml_writer, folder, request, href, &[])
}
fn write_folder_response_with_dead_props<W: Write>(
xml_writer: &mut Writer<W>,
folder: &FolderDto,
request: &PropFindRequest,
href: &str,
dead_props: &[(QualifiedName, Option<String>)],
) -> Result<()> { ) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
@@ -371,11 +468,30 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Text(BytesText::new(href)))?; xml_writer.write_event(Event::Text(BytesText::new(href)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
// Compute dead props first so we can exclude them from the 404 propstat.
let relevant_dead: Vec<_> = match &request.prop_find_type {
PropFindType::Prop(requested) => dead_props
.iter()
.filter(|(name, _)| requested.iter().any(|r| r == name))
.cloned()
.collect(),
PropFindType::AllProp => dead_props.to_vec(),
PropFindType::PropName => vec![],
};
let dead_name_set: std::collections::HashSet<&QualifiedName> =
relevant_dead.iter().map(|(n, _)| n).collect();
match &request.prop_find_type { match &request.prop_find_type {
PropFindType::Prop(props) => { PropFindType::Prop(props) => {
// RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat. // RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat.
// Props found in the dead store are returned in the dead 200 propstat,
// so exclude them from the 404 propstat to avoid duplicate reporting.
let (known, unknown): (Vec<_>, Vec<_>) = let (known, unknown): (Vec<_>, Vec<_>) =
props.iter().partition(|p| Self::folder_prop_is_known(p)); props.iter().partition(|p| Self::prop_is_known(p));
let truly_unknown: Vec<_> = unknown
.into_iter()
.filter(|p| !dead_name_set.contains(*p))
.collect();
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
@@ -386,7 +502,7 @@ impl WebDavAdapter {
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
Self::write_unknown_props_404(xml_writer, &unknown)?; Self::write_unknown_props_404(xml_writer, &truly_unknown)?;
} }
other => { other => {
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
@@ -408,6 +524,9 @@ impl WebDavAdapter {
} }
} }
// Dead properties — written as a separate 200 propstat (RFC 4918 §4.2).
Self::write_dead_props_propstat(xml_writer, &relevant_dead)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
Ok(()) Ok(())
} }
@@ -418,6 +537,16 @@ impl WebDavAdapter {
file: &FileDto, file: &FileDto,
request: &PropFindRequest, request: &PropFindRequest,
href: &str, href: &str,
) -> Result<()> {
Self::write_file_response_with_dead_props(xml_writer, file, request, href, &[])
}
fn write_file_response_with_dead_props<W: Write>(
xml_writer: &mut Writer<W>,
file: &FileDto,
request: &PropFindRequest,
href: &str,
dead_props: &[(QualifiedName, Option<String>)],
) -> Result<()> { ) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
@@ -425,11 +554,30 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Text(BytesText::new(href)))?; xml_writer.write_event(Event::Text(BytesText::new(href)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
// Compute dead props first so we can exclude them from the 404 propstat.
let relevant_dead: Vec<_> = match &request.prop_find_type {
PropFindType::Prop(requested) => dead_props
.iter()
.filter(|(name, _)| requested.iter().any(|r| r == name))
.cloned()
.collect(),
PropFindType::AllProp => dead_props.to_vec(),
PropFindType::PropName => vec![],
};
let dead_name_set: std::collections::HashSet<&QualifiedName> =
relevant_dead.iter().map(|(n, _)| n).collect();
match &request.prop_find_type { match &request.prop_find_type {
PropFindType::Prop(props) => { PropFindType::Prop(props) => {
// RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat. // RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat.
// Props found in the dead store are returned in the dead 200 propstat,
// so exclude them from the 404 propstat to avoid duplicate reporting.
let (known, unknown): (Vec<_>, Vec<_>) = let (known, unknown): (Vec<_>, Vec<_>) =
props.iter().partition(|p| Self::file_prop_is_known(p)); props.iter().partition(|p| Self::prop_is_known(p));
let truly_unknown: Vec<_> = unknown
.into_iter()
.filter(|p| !dead_name_set.contains(*p))
.collect();
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
@@ -440,7 +588,7 @@ impl WebDavAdapter {
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
Self::write_unknown_props_404(xml_writer, &unknown)?; Self::write_unknown_props_404(xml_writer, &truly_unknown)?;
} }
other => { other => {
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
@@ -462,6 +610,9 @@ impl WebDavAdapter {
} }
} }
// Dead properties (RFC 4918 §4.2).
Self::write_dead_props_propstat(xml_writer, &relevant_dead)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
Ok(()) Ok(())
} }
@@ -606,7 +757,7 @@ impl WebDavAdapter {
fn write_folder_requested_props<W: Write>( fn write_folder_requested_props<W: Write>(
xml_writer: &mut Writer<W>, xml_writer: &mut Writer<W>,
folder: &FolderDto, folder: &FolderDto,
props: &[QualifiedName], props: &[&QualifiedName],
) -> Result<()> { ) -> Result<()> {
for prop in props { for prop in props {
if prop.namespace == "DAV:" { if prop.namespace == "DAV:" {
@@ -682,7 +833,7 @@ impl WebDavAdapter {
fn write_file_requested_props<W: Write>( fn write_file_requested_props<W: Write>(
xml_writer: &mut Writer<W>, xml_writer: &mut Writer<W>,
file: &FileDto, file: &FileDto,
props: &[QualifiedName], props: &[&QualifiedName],
) -> Result<()> { ) -> Result<()> {
for prop in props { for prop in props {
if prop.namespace == "DAV:" { if prop.namespace == "DAV:" {
@@ -752,8 +903,11 @@ impl WebDavAdapter {
Ok(()) Ok(())
} }
/// Parse a PROPPATCH XML request /// Parse a PROPPATCH XML request.
pub fn parse_proppatch<R: Read>(reader: R) -> Result<(Vec<PropValue>, Vec<QualifiedName>)> { ///
/// Returns operations in document order (RFC 4918 §9.2 requires document-order
/// processing so that remove-then-set and set-then-remove yield different results).
pub fn parse_proppatch<R: Read>(reader: R) -> Result<Vec<PropPatchOp>> {
let mut xml_reader = Reader::from_reader(BufReader::new(reader)); let mut xml_reader = Reader::from_reader(BufReader::new(reader));
xml_reader.config_mut().trim_text(true); xml_reader.config_mut().trim_text(true);
@@ -763,8 +917,7 @@ impl WebDavAdapter {
let mut in_remove = false; let mut in_remove = false;
let mut in_prop = false; let mut in_prop = false;
let mut current_prop: Option<QualifiedName> = None; let mut current_prop: Option<QualifiedName> = None;
let mut props_to_set = Vec::new(); let mut ops: Vec<PropPatchOp> = Vec::new();
let mut props_to_remove = Vec::new();
let mut current_text = String::new(); let mut current_text = String::new();
let mut ns_map = std::collections::HashMap::<String, String>::new(); let mut ns_map = std::collections::HashMap::<String, String>::new();
@@ -796,7 +949,30 @@ impl WebDavAdapter {
} }
} }
Ok(Event::Text(e)) if current_prop.is_some() => { Ok(Event::Text(e)) if current_prop.is_some() => {
current_text.push_str(&e.decode().unwrap_or_default()); let raw = e.decode().unwrap_or_default();
let unescaped =
quick_xml::escape::unescape(&raw).unwrap_or_else(|_| raw.clone());
current_text.push_str(&unescaped);
}
Ok(Event::GeneralRef(ref e)) if current_prop.is_some() => {
// quick-xml 0.39 emits GeneralRef for character references like &#65536;
// and named entity references like &amp;. Resolve them to actual chars.
match e.resolve_char_ref() {
Ok(Some(ch)) => current_text.push(ch),
Ok(None) => {
if let Ok(name) = e.decode() {
match name.as_ref() {
"amp" => current_text.push('&'),
"lt" => current_text.push('<'),
"gt" => current_text.push('>'),
"apos" => current_text.push('\''),
"quot" => current_text.push('"'),
_ => {}
}
}
}
Err(_) => {}
}
} }
Ok(Event::End(ref e)) => { Ok(Event::End(ref e)) => {
let name = e.name(); let name = e.name();
@@ -810,19 +986,18 @@ impl WebDavAdapter {
s if s == "remove" || s.ends_with(":remove") => in_remove = false, s if s == "remove" || s.ends_with(":remove") => in_remove = false,
s if s == "prop" || s.ends_with(":prop") => in_prop = false, s if s == "prop" || s.ends_with(":prop") => in_prop = false,
_ if in_prop => { _ if in_prop => {
// End of property element
if let Some(prop_name) = current_prop.take() { if let Some(prop_name) = current_prop.take() {
if in_set { if in_set {
props_to_set.push(PropValue { ops.push(PropPatchOp::Set(PropValue {
name: prop_name, name: prop_name,
value: if current_text.is_empty() { value: if current_text.is_empty() {
None None
} else { } else {
Some(current_text.clone()) Some(current_text.clone())
}, },
}); }));
} else if in_remove { } else if in_remove {
props_to_remove.push(prop_name); ops.push(PropPatchOp::Remove(prop_name));
} }
} }
current_text.clear(); current_text.clear();
@@ -839,12 +1014,12 @@ impl WebDavAdapter {
let qname = Self::resolve_name(name_str, &ns_map); let qname = Self::resolve_name(name_str, &ns_map);
if in_set { if in_set {
props_to_set.push(PropValue { ops.push(PropPatchOp::Set(PropValue {
name: qname, name: qname,
value: None, value: None,
}); }));
} else if in_remove { } else if in_remove {
props_to_remove.push(qname); ops.push(PropPatchOp::Remove(qname));
} }
} }
} }
@@ -856,7 +1031,7 @@ impl WebDavAdapter {
buffer.clear(); buffer.clear();
} }
Ok((props_to_set, props_to_remove)) Ok(ops)
} }
/// Generate a PROPPATCH response /// Generate a PROPPATCH response
@@ -901,12 +1076,7 @@ impl WebDavAdapter {
// Write property names // Write property names
for prop in success_props { for prop in success_props {
let prop_name = if prop.namespace == "DAV:" { Self::write_qname_empty(&mut xml_writer, prop)?;
format!("D:{}", prop.name)
} else {
format!("{}:{}", prop.namespace, prop.name)
};
xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?;
} }
// End prop // End prop
@@ -930,12 +1100,7 @@ impl WebDavAdapter {
// Write property names // Write property names
for prop in failed_props { for prop in failed_props {
let prop_name = if prop.namespace == "DAV:" { Self::write_qname_empty(&mut xml_writer, prop)?;
format!("D:{}", prop.name)
} else {
format!("{}:{}", prop.namespace, prop.name)
};
xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?;
} }
// End prop // End prop
@@ -1182,7 +1347,7 @@ impl WebDavAdapter {
Self::write_folder_response(writer, folder, request, href) Self::write_folder_response(writer, folder, request, href)
} }
/// Writes a single `<D:response>` element for a file. /// Writes a single `<D:response>` element for a file, including dead properties.
pub fn write_file_entry<W: Write>( pub fn write_file_entry<W: Write>(
writer: &mut Writer<W>, writer: &mut Writer<W>,
file: &FileDto, file: &FileDto,
@@ -1191,4 +1356,26 @@ impl WebDavAdapter {
) -> Result<()> { ) -> Result<()> {
Self::write_file_response(writer, file, request, href) Self::write_file_response(writer, file, request, href)
} }
/// Writes a folder entry including dead (custom) properties.
pub fn write_folder_entry_with_dead_props<W: Write>(
writer: &mut Writer<W>,
folder: &FolderDto,
request: &PropFindRequest,
href: &str,
dead_props: &[(QualifiedName, Option<String>)],
) -> Result<()> {
Self::write_folder_response_with_dead_props(writer, folder, request, href, dead_props)
}
/// Writes a file entry including dead (custom) properties.
pub fn write_file_entry_with_dead_props<W: Write>(
writer: &mut Writer<W>,
file: &FileDto,
request: &PropFindRequest,
href: &str,
dead_props: &[(QualifiedName, Option<String>)],
) -> Result<()> {
Self::write_file_response_with_dead_props(writer, file, request, href, dead_props)
}
} }
+4
View File
@@ -1542,6 +1542,8 @@ impl AppServiceFactory {
path_resolver: None, path_resolver: None,
webdav_lock_store: webdav_lock_store:
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(), crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
webdav_dead_props:
crate::infrastructure::services::webdav_dead_property_store::create_dead_property_store(pool.clone()),
authorization: authorization.clone(), authorization: authorization.clone(),
drive_repo: drive_repo.clone(), drive_repo: drive_repo.clone(),
drive_management_service: Arc::new( drive_management_service: Arc::new(
@@ -2010,6 +2012,8 @@ pub struct AppState {
Option<Arc<crate::infrastructure::services::path_resolver_service::PathResolverService>>, Option<Arc<crate::infrastructure::services::path_resolver_service::PathResolverService>>,
pub webdav_lock_store: pub webdav_lock_store:
Arc<crate::infrastructure::services::webdav_lock_service::WebDavLockStore>, Arc<crate::infrastructure::services::webdav_lock_service::WebDavLockStore>,
pub webdav_dead_props:
Arc<crate::infrastructure::services::webdav_dead_property_store::DeadPropertyStore>,
/// ReBAC authorization engine — all service-layer permission checks go /// ReBAC authorization engine — all service-layer permission checks go
/// through this. Concrete type today is `PgAclEngine`; the /// through this. Concrete type today is `PgAclEngine`; the
/// `AuthorizationEngine` trait describes the contract. When alternate /// `AuthorizationEngine` trait describes the contract. When alternate
+1
View File
@@ -42,6 +42,7 @@ pub mod thumbnail_service;
mod thumbnail_service_test; mod thumbnail_service_test;
pub mod trash_cleanup_service; pub mod trash_cleanup_service;
pub mod tree_etag_flush_service; pub mod tree_etag_flush_service;
pub mod webdav_dead_property_store;
pub mod webdav_lock_service; pub mod webdav_lock_service;
pub mod wopi_discovery_service; pub mod wopi_discovery_service;
pub mod zip_service; pub mod zip_service;
@@ -0,0 +1,184 @@
//! PostgreSQL-backed dead property store for WebDAV PROPPATCH / PROPFIND compliance.
//!
//! RFC 4918 §4.2 defines "dead properties" as those stored verbatim by the
//! server without interpreting their value. Properties are persisted to
//! `storage.webdav_dead_properties` and survive server restarts.
use std::sync::Arc;
use sqlx::PgPool;
use uuid::Uuid;
use crate::application::adapters::webdav_adapter::QualifiedName;
use crate::domain::errors::DomainError;
pub struct DeadPropertyStore {
pool: Arc<PgPool>,
}
impl DeadPropertyStore {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// Upsert a dead property. `value = None` means an empty XML element.
pub async fn set(
&self,
path: &str,
user_id: Uuid,
name: QualifiedName,
value: Option<String>,
) -> Result<(), DomainError> {
sqlx::query!(
r#"
INSERT INTO storage.webdav_dead_properties
(resource_path, user_id, namespace, local_name, value)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (resource_path, user_id, namespace, local_name)
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
"#,
path,
user_id,
name.namespace,
name.name,
value,
)
.execute(&*self.pool)
.await
.map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("set: {e}")))?;
Ok(())
}
/// Delete a specific dead property. No-op if not present.
pub async fn remove(
&self,
path: &str,
user_id: Uuid,
name: &QualifiedName,
) -> Result<(), DomainError> {
sqlx::query!(
"DELETE FROM storage.webdav_dead_properties
WHERE resource_path = $1 AND user_id = $2
AND namespace = $3 AND local_name = $4",
path,
user_id,
name.namespace,
name.name,
)
.execute(&*self.pool)
.await
.map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("remove: {e}")))?;
Ok(())
}
/// Return all dead properties for `path`.
pub async fn get_all(
&self,
path: &str,
user_id: Uuid,
) -> Result<Vec<(QualifiedName, Option<String>)>, DomainError> {
let rows = sqlx::query!(
"SELECT namespace, local_name, value
FROM storage.webdav_dead_properties
WHERE resource_path = $1 AND user_id = $2",
path,
user_id,
)
.fetch_all(&*self.pool)
.await
.map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("get_all: {e}")))?;
Ok(rows
.into_iter()
.map(|r| (QualifiedName::new(r.namespace, r.local_name), r.value))
.collect())
}
/// Return a specific dead property, or `None` if not stored.
/// Returns `Some(None)` when the property exists with an empty value.
pub async fn get(
&self,
path: &str,
user_id: Uuid,
name: &QualifiedName,
) -> Result<Option<Option<String>>, DomainError> {
let row = sqlx::query!(
"SELECT value FROM storage.webdav_dead_properties
WHERE resource_path = $1 AND user_id = $2
AND namespace = $3 AND local_name = $4",
path,
user_id,
name.namespace,
name.name,
)
.fetch_optional(&*self.pool)
.await
.map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("get: {e}")))?;
Ok(row.map(|r| r.value))
}
/// Delete all dead properties for `path` (called on DELETE).
pub async fn remove_resource(&self, path: &str, user_id: Uuid) -> Result<(), DomainError> {
sqlx::query!(
"DELETE FROM storage.webdav_dead_properties
WHERE resource_path = $1 AND user_id = $2",
path,
user_id,
)
.execute(&*self.pool)
.await
.map_err(|e| {
DomainError::internal_error("DeadPropertyStore", format!("remove_resource: {e}"))
})?;
Ok(())
}
/// Move dead properties from `old_path` to `new_path` (called on MOVE).
/// Clears any stale properties at `new_path` first.
pub async fn rename_resource(
&self,
old_path: &str,
user_id: Uuid,
new_path: &str,
) -> Result<(), DomainError> {
let mut tx = self.pool.begin().await.map_err(|e| {
DomainError::internal_error("DeadPropertyStore", format!("rename_resource tx: {e}"))
})?;
sqlx::query!(
"DELETE FROM storage.webdav_dead_properties
WHERE resource_path = $1 AND user_id = $2",
new_path,
user_id,
)
.execute(&mut *tx)
.await
.map_err(|e| {
DomainError::internal_error("DeadPropertyStore", format!("rename_resource delete: {e}"))
})?;
sqlx::query!(
"UPDATE storage.webdav_dead_properties
SET resource_path = $2
WHERE resource_path = $1 AND user_id = $3",
old_path,
new_path,
user_id,
)
.execute(&mut *tx)
.await
.map_err(|e| {
DomainError::internal_error("DeadPropertyStore", format!("rename_resource update: {e}"))
})?;
tx.commit().await.map_err(|e| {
DomainError::internal_error("DeadPropertyStore", format!("rename_resource commit: {e}"))
})?;
Ok(())
}
}
pub fn create_dead_property_store(pool: Arc<PgPool>) -> Arc<DeadPropertyStore> {
Arc::new(DeadPropertyStore::new(pool))
}
+98 -37
View File
@@ -17,7 +17,9 @@ use chrono::Utc;
use quick_xml::Writer; use quick_xml::Writer;
use uuid::Uuid; use uuid::Uuid;
use crate::application::adapters::webdav_adapter::{LockInfo, PropFindRequest, WebDavAdapter}; use crate::application::adapters::webdav_adapter::{
LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter,
};
use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::folder_dto::FolderDto;
use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::file_ports::FileRetrievalUseCase;
@@ -391,6 +393,12 @@ async fn handle_propfind(
// ── 2. Authenticate ────────────────────────────────────────── // ── 2. Authenticate ──────────────────────────────────────────
let user = extract_user(&req)?; let user = extract_user(&req)?;
// Client-facing path for href construction — must be extracted before
// req.into_body() consumes the request. The `path` parameter already has
// the home-folder prefix prepended (e.g. `admin/docs`) so it's correct for
// DB lookups but wrong for WebDAV hrefs (clients see `/webdav/docs`).
let client_path = extract_webdav_path(req.uri());
// ── 3. Parse PROPFIND XML body ─────────────────────────────── // ── 3. Parse PROPFIND XML body ───────────────────────────────
let body_bytes = { let body_bytes = {
let body = req.into_body(); let body = req.into_body();
@@ -413,10 +421,11 @@ async fn handle_propfind(
let folder_service = state.applications.folder_service.clone(); let folder_service = state.applications.folder_service.clone();
let file_retrieval_service = state.applications.file_retrieval_service.clone(); let file_retrieval_service = state.applications.file_retrieval_service.clone();
let base_href = if path.is_empty() || path == "/" { // Use client-facing path for hrefs so responses match the request URL.
let base_href = if client_path.is_empty() || client_path == "/" {
"/webdav/".to_string() "/webdav/".to_string()
} else { } else {
format!("/webdav/{}/", encode_uri_path(&path)) format!("/webdav/{}/", encode_uri_path(&client_path))
}; };
// ── 5. Determine target resource ───────────────────────────── // ── 5. Determine target resource ─────────────────────────────
@@ -452,6 +461,8 @@ async fn handle_propfind(
folder_service, folder_service,
file_retrieval_service, file_retrieval_service,
user.id, user.id,
state.webdav_dead_props.clone(),
path.clone(),
) )
.await; .await;
} }
@@ -470,20 +481,29 @@ async fn handle_propfind(
folder_service, folder_service,
file_retrieval_service, file_retrieval_service,
user.id, user.id,
state.webdav_dead_props.clone(),
path.clone(),
) )
.await; .await;
} }
Ok(ResolvedResource::File(file)) => { Ok(ResolvedResource::File(file)) => {
let dead_props = state
.webdav_dead_props
.get_all(&path, user.id)
.await
.unwrap_or_default();
let file_href = webdav_href(&client_path);
let mut buf = Vec::with_capacity(1024); let mut buf = Vec::with_capacity(1024);
{ {
let mut xml_writer = Writer::new(&mut buf); let mut xml_writer = Writer::new(&mut buf);
WebDavAdapter::write_multistatus_start(&mut xml_writer) WebDavAdapter::write_multistatus_start(&mut xml_writer)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
WebDavAdapter::write_file_entry( WebDavAdapter::write_file_entry_with_dead_props(
&mut xml_writer, &mut xml_writer,
&file, &file,
&propfind_request, &propfind_request,
&base_href, &file_href,
&dead_props,
) )
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
WebDavAdapter::write_multistatus_end(&mut xml_writer) WebDavAdapter::write_multistatus_end(&mut xml_writer)
@@ -514,6 +534,8 @@ async fn handle_propfind(
folder_service, folder_service,
file_retrieval_service, file_retrieval_service,
user.id, user.id,
state.webdav_dead_props.clone(),
path.clone(),
) )
.await; .await;
} }
@@ -522,16 +544,23 @@ async fn handle_propfind(
.await .await
{ {
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?; assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
let dead_props = state
.webdav_dead_props
.get_all(&path, user.id)
.await
.unwrap_or_default();
let file_href = webdav_href(&client_path);
let mut buf = Vec::with_capacity(1024); let mut buf = Vec::with_capacity(1024);
{ {
let mut xml_writer = Writer::new(&mut buf); let mut xml_writer = Writer::new(&mut buf);
WebDavAdapter::write_multistatus_start(&mut xml_writer) WebDavAdapter::write_multistatus_start(&mut xml_writer)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
WebDavAdapter::write_file_entry( WebDavAdapter::write_file_entry_with_dead_props(
&mut xml_writer, &mut xml_writer,
&file, &file,
&propfind_request, &propfind_request,
&base_href, &file_href,
&dead_props,
) )
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
WebDavAdapter::write_multistatus_end(&mut xml_writer) WebDavAdapter::write_multistatus_end(&mut xml_writer)
@@ -564,6 +593,10 @@ async fn build_streaming_propfind_response(
folder_service: std::sync::Arc<FolderService>, folder_service: std::sync::Arc<FolderService>,
file_retrieval_service: std::sync::Arc<FileRetrievalService>, file_retrieval_service: std::sync::Arc<FileRetrievalService>,
user_id: Uuid, user_id: Uuid,
dead_props_store: Arc<
crate::infrastructure::services::webdav_dead_property_store::DeadPropertyStore,
>,
folder_internal_path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
let depth = depth.to_string(); let depth = depth.to_string();
let base_href = base_href.to_string(); let base_href = base_href.to_string();
@@ -574,9 +607,11 @@ async fn build_streaming_propfind_response(
let mut buf = Vec::with_capacity(4096); let mut buf = Vec::with_capacity(4096);
{ {
let mut w = Writer::new(&mut buf); let mut w = Writer::new(&mut buf);
let folder_dead = dead_props_store.get_all(&folder_internal_path, user_id).await
.map_err(|e| std::io::Error::other(e.to_string()))?;
WebDavAdapter::write_multistatus_start(&mut w) WebDavAdapter::write_multistatus_start(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?; .map_err(|e| std::io::Error::other(e.to_string()))?;
WebDavAdapter::write_folder_entry(&mut w, &folder, &propfind_request, &base_href) WebDavAdapter::write_folder_entry_with_dead_props(&mut w, &folder, &propfind_request, &base_href, &folder_dead)
.map_err(|e| std::io::Error::other(e.to_string()))?; .map_err(|e| std::io::Error::other(e.to_string()))?;
} }
yield Bytes::from(buf); yield Bytes::from(buf);
@@ -610,7 +645,10 @@ async fn build_streaming_propfind_response(
let mut w = Writer::new(&mut chunk); let mut w = Writer::new(&mut chunk);
for subfolder in &result.items { for subfolder in &result.items {
let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name)); let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name));
WebDavAdapter::write_folder_entry(&mut w, subfolder, &propfind_request, &href) let child_path = format!("{}/{}", folder_internal_path, subfolder.name);
let child_dead = dead_props_store.get_all(&child_path, user_id).await
.map_err(|e| std::io::Error::other(e.to_string()))?;
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, &child_dead)
.map_err(|e| std::io::Error::other(e.to_string()))?; .map_err(|e| std::io::Error::other(e.to_string()))?;
} }
} }
@@ -641,7 +679,10 @@ async fn build_streaming_propfind_response(
let mut w = Writer::new(&mut chunk); let mut w = Writer::new(&mut chunk);
for file in &batch { for file in &batch {
let href = format!("{}{}", base_href, encode_path_segment(&file.name)); let href = format!("{}{}", base_href, encode_path_segment(&file.name));
WebDavAdapter::write_file_entry(&mut w, file, &propfind_request, &href) let child_path = format!("{}/{}", folder_internal_path, file.name);
let child_dead = dead_props_store.get_all(&child_path, user_id).await
.map_err(|e| std::io::Error::other(e.to_string()))?;
WebDavAdapter::write_file_entry_with_dead_props(&mut w, file, &propfind_request, &href, &child_dead)
.map_err(|e| std::io::Error::other(e.to_string()))?; .map_err(|e| std::io::Error::other(e.to_string()))?;
} }
} }
@@ -693,6 +734,8 @@ async fn handle_proppatch(
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?; let user = extract_user(&req)?;
// Client-facing path for href construction (without home folder prefix).
let client_path = extract_webdav_path(req.uri());
// Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties, // Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties,
// so a lock on the target must release them via `If:`. Captured // so a lock on the target must release them via `If:`. Captured
@@ -739,30 +782,37 @@ async fn handle_proppatch(
.map_err(|e| { .map_err(|e| {
AppError::payload_too_large(format!("PROPPATCH body too large or unreadable: {}", e)) AppError::payload_too_large(format!("PROPPATCH body too large or unreadable: {}", e))
})?; })?;
let (props_to_set, props_to_remove) = WebDavAdapter::parse_proppatch(body_bytes.reader()) let ops = WebDavAdapter::parse_proppatch(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?; .map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?;
// For now, we don't actually persist custom properties, but we respond as if we did // Apply operations in document order (RFC 4918 §9.2).
// In a full implementation, we would store these properties in a database let dead_props = &state.webdav_dead_props;
let mut results: Vec<(&QualifiedName, bool)> = Vec::new();
// Generate response - we'll pretend all operations succeeded for op in &ops {
let mut results = Vec::new(); match op {
PropPatchOp::Set(pv) => {
// For each property to set, indicate success dead_props
for prop in &props_to_set { .set(&path, user.id, pv.name.clone(), pv.value.clone())
results.push((&prop.name, true)); .await
.map_err(|e| {
AppError::internal_error(format!("Failed to store dead property: {e}"))
})?;
results.push((&pv.name, true));
}
PropPatchOp::Remove(name) => {
dead_props.remove(&path, user.id, name).await.map_err(|e| {
AppError::internal_error(format!("Failed to remove dead property: {e}"))
})?;
results.push((name, true));
}
}
} }
// For each property to remove, indicate success // Generate response — use client-facing path so href matches the request URL.
for prop in &props_to_remove {
results.push((prop, true));
}
// Generate response — collection vs file href chosen above.
let href = if is_collection { let href = if is_collection {
webdav_collection_href(&path) webdav_collection_href(&client_path)
} else { } else {
webdav_href(&path) webdav_href(&client_path)
}; };
let mut response_body = Vec::new(); let mut response_body = Vec::new();
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err( WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
@@ -1073,12 +1123,12 @@ fn enforce_native_lock(
if p.is_empty() { if p.is_empty() {
return None; return None;
} }
if let Some(e) = lock_store.get_by_path(p) { if let Some(e) = lock_store.get_by_path(p)
if e.info.depth.eq_ignore_ascii_case("infinity") { && e.info.depth.eq_ignore_ascii_case("infinity")
{
return Some(e); return Some(e);
} }
} }
}
}); });
if let Some(entry) = entry { if let Some(entry) = entry {
@@ -1216,10 +1266,7 @@ async fn handle_put(
.resolve_path_for_user(parent_path, user.id) .resolve_path_for_user(parent_path, user.id)
.await .await
.map_err(|_| { .map_err(|_| {
AppError::conflict(format!( AppError::conflict(format!("Parent folder not found: {}", parent_path))
"Parent folder not found: {}",
parent_path
))
})?; })?;
} }
} }
@@ -1410,7 +1457,11 @@ async fn handle_mkcol(
"AlreadyExists", "AlreadyExists",
)); ));
} }
} else if folder_service.get_folder_by_path(&path, drive_id).await.is_ok() { } else if folder_service
.get_folder_by_path(&path, drive_id)
.await
.is_ok()
{
return Err(AppError::new( return Err(AppError::new(
StatusCode::METHOD_NOT_ALLOWED, StatusCode::METHOD_NOT_ALLOWED,
"Collection already exists", "Collection already exists",
@@ -1446,7 +1497,10 @@ async fn handle_mkcol(
} }
} }
} else { } else {
match folder_service.get_folder_by_path(&parent_path, drive_id).await { match folder_service
.get_folder_by_path(&parent_path, drive_id)
.await
{
Ok(f) => Some(f.id), Ok(f) => Some(f.id),
Err(_) => { Err(_) => {
return Err(AppError::conflict(format!( return Err(AppError::conflict(format!(
@@ -1789,6 +1843,13 @@ async fn handle_move(
} }
} }
// Migrate dead properties to the new path (RFC 4918 §9.9 — MOVE preserves properties).
state
.webdav_dead_props
.rename_resource(&source_path, user.id, &destination_path)
.await
.map_err(|e| AppError::internal_error(format!("Failed to migrate dead properties: {e}")))?;
// RFC 4918 §9.9.5: 201 Created when destination is new, 204 when overwritten. // RFC 4918 §9.9.5: 201 Created when destination is new, 204 when overwritten.
let status = if dest_existed { let status = if dest_existed {
StatusCode::NO_CONTENT StatusCode::NO_CONTENT