diff --git a/Cargo.lock b/Cargo.lock index 94a9334..5c474f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -822,6 +822,22 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "aster_forge_webdav" +version = "0.1.0" +dependencies = [ + "actix-web", + "aster_forge_utils", + "aster_forge_xml", + "async-trait", + "bytes", + "chrono", + "futures", + "http 1.4.2", + "percent-encoding", + "thiserror 2.0.19", +] + [[package]] name = "aster_forge_xml" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e3fbb6a..7371ddf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ members = [ "crates/aster_forge_test", "crates/aster_forge_utils", "crates/aster_forge_validation", + "crates/aster_forge_webdav", "crates/aster_forge_xml", ] resolver = "3" @@ -46,6 +47,7 @@ async-trait = "0.1" argon2 = "0.5.3" base64 = "0.22" bloomfilter = "3.0.1" +bytes = "1" chrono = { version = "0.4.45", default-features = false, features = ["serde"] } criterion = { version = "0.8", features = ["async_tokio"] } dashmap = "6" @@ -59,6 +61,7 @@ lettre = { version = "0.11", default-features = false, features = ["builder", "s md-5 = "0.11" moka = { version = "0.12.15", features = ["future"] } parking_lot = "0.12" +percent-encoding = "2.3" proc-macro2 = "1" quick-xml = { version = "0.41", default-features = false } quote = "1" diff --git a/README.md b/README.md index a498519..8c7530f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ All crate names use the `aster_forge_*` prefix. The workspace targets Rust `1.94 | Area | Crates | | --- | --- | | Runtime kernel | [`aster_forge_runtime`](https://forge.astercosm.com/crates/aster_forge_runtime), [`aster_forge_config`](https://forge.astercosm.com/crates/aster_forge_config), [`aster_forge_logging`](https://forge.astercosm.com/crates/aster_forge_logging), [`aster_forge_metrics`](https://forge.astercosm.com/crates/aster_forge_metrics), [`aster_forge_panic`](https://forge.astercosm.com/crates/aster_forge_panic), [`aster_forge_alloc`](https://forge.astercosm.com/crates/aster_forge_alloc) | -| Web and API | [`aster_forge_api`](https://forge.astercosm.com/crates/aster_forge_api), [`aster_forge_api_docs_macros`](https://forge.astercosm.com/crates/aster_forge_api_docs_macros), [`aster_forge_actix_middleware`](https://forge.astercosm.com/crates/aster_forge_actix_middleware), [`aster_forge_actix_observability`](https://forge.astercosm.com/crates/aster_forge_actix_observability), [`aster_forge_external_auth`](https://forge.astercosm.com/crates/aster_forge_external_auth) | +| Web and API | [`aster_forge_api`](https://forge.astercosm.com/crates/aster_forge_api), [`aster_forge_api_docs_macros`](https://forge.astercosm.com/crates/aster_forge_api_docs_macros), [`aster_forge_actix_middleware`](https://forge.astercosm.com/crates/aster_forge_actix_middleware), [`aster_forge_actix_observability`](https://forge.astercosm.com/crates/aster_forge_actix_observability), [`aster_forge_external_auth`](https://forge.astercosm.com/crates/aster_forge_external_auth), [`aster_forge_webdav`](https://forge.astercosm.com/crates/aster_forge_webdav) | | Data, coordination, and background work | [`aster_forge_db`](https://forge.astercosm.com/crates/aster_forge_db), [`aster_forge_cache`](https://forge.astercosm.com/crates/aster_forge_cache), [`aster_forge_tasks`](https://forge.astercosm.com/crates/aster_forge_tasks), [`aster_forge_mail`](https://forge.astercosm.com/crates/aster_forge_mail), [`aster_forge_audit`](https://forge.astercosm.com/crates/aster_forge_audit) | | Storage and domain-neutral helpers | [`aster_forge_storage_core`](https://forge.astercosm.com/crates/aster_forge_storage_core), [`aster_forge_file_classification`](https://forge.astercosm.com/crates/aster_forge_file_classification) | | Utilities | [`aster_forge_crypto`](https://forge.astercosm.com/crates/aster_forge_crypto), [`aster_forge_utils`](https://forge.astercosm.com/crates/aster_forge_utils), [`aster_forge_validation`](https://forge.astercosm.com/crates/aster_forge_validation), [`aster_forge_xml`](https://forge.astercosm.com/crates/aster_forge_xml) | diff --git a/README.zh.md b/README.zh.md index d701cc5..348ea72 100644 --- a/README.zh.md +++ b/README.zh.md @@ -52,7 +52,7 @@ aster_forge_runtime::AsterRuntime::builder() | 领域 | Crates | | --- | --- | | 运行时内核 | [`aster_forge_runtime`](https://forge.astercosm.com/crates/aster_forge_runtime)、[`aster_forge_config`](https://forge.astercosm.com/crates/aster_forge_config)、[`aster_forge_logging`](https://forge.astercosm.com/crates/aster_forge_logging)、[`aster_forge_metrics`](https://forge.astercosm.com/crates/aster_forge_metrics)、[`aster_forge_panic`](https://forge.astercosm.com/crates/aster_forge_panic)、[`aster_forge_alloc`](https://forge.astercosm.com/crates/aster_forge_alloc) | -| Web 与 API | [`aster_forge_api`](https://forge.astercosm.com/crates/aster_forge_api)、[`aster_forge_api_docs_macros`](https://forge.astercosm.com/crates/aster_forge_api_docs_macros)、[`aster_forge_actix_middleware`](https://forge.astercosm.com/crates/aster_forge_actix_middleware)、[`aster_forge_actix_observability`](https://forge.astercosm.com/crates/aster_forge_actix_observability)、[`aster_forge_external_auth`](https://forge.astercosm.com/crates/aster_forge_external_auth) | +| Web 与 API | [`aster_forge_api`](https://forge.astercosm.com/crates/aster_forge_api)、[`aster_forge_api_docs_macros`](https://forge.astercosm.com/crates/aster_forge_api_docs_macros)、[`aster_forge_actix_middleware`](https://forge.astercosm.com/crates/aster_forge_actix_middleware)、[`aster_forge_actix_observability`](https://forge.astercosm.com/crates/aster_forge_actix_observability)、[`aster_forge_external_auth`](https://forge.astercosm.com/crates/aster_forge_external_auth)、[`aster_forge_webdav`](https://forge.astercosm.com/crates/aster_forge_webdav) | | 数据、协调与后台任务 | [`aster_forge_db`](https://forge.astercosm.com/crates/aster_forge_db)、[`aster_forge_cache`](https://forge.astercosm.com/crates/aster_forge_cache)、[`aster_forge_tasks`](https://forge.astercosm.com/crates/aster_forge_tasks)、[`aster_forge_mail`](https://forge.astercosm.com/crates/aster_forge_mail)、[`aster_forge_audit`](https://forge.astercosm.com/crates/aster_forge_audit) | | 存储与领域无关辅助 | [`aster_forge_storage_core`](https://forge.astercosm.com/crates/aster_forge_storage_core)、[`aster_forge_file_classification`](https://forge.astercosm.com/crates/aster_forge_file_classification) | | 工具类 | [`aster_forge_crypto`](https://forge.astercosm.com/crates/aster_forge_crypto)、[`aster_forge_utils`](https://forge.astercosm.com/crates/aster_forge_utils)、[`aster_forge_validation`](https://forge.astercosm.com/crates/aster_forge_validation) | diff --git a/crates/aster_forge_metrics/src/prometheus.rs b/crates/aster_forge_metrics/src/prometheus.rs index 04b0889..56c84f5 100644 --- a/crates/aster_forge_metrics/src/prometheus.rs +++ b/crates/aster_forge_metrics/src/prometheus.rs @@ -1,7 +1,7 @@ //! Prometheus backend for shared Aster infrastructure metrics. //! //! This module is enabled by the `prometheus` feature. Product crates can use -//! [`init_or_noop`] to obtain a [`SharedMetricsRecorder`](crate::SharedMetricsRecorder) +//! [`init_or_noop`] to obtain a [`SharedMetricsRecorder`] //! and [`export_metrics`] for their HTTP metrics endpoint without depending on //! the `prometheus` crate directly. Product-specific metric families can be //! registered with Forge descriptors and recorded through opaque handles, so diff --git a/crates/aster_forge_utils/src/http_range.rs b/crates/aster_forge_utils/src/http_range.rs new file mode 100644 index 0000000..7246882 --- /dev/null +++ b/crates/aster_forge_utils/src/http_range.rs @@ -0,0 +1,221 @@ +//! Transport-neutral parsing for a single HTTP byte range. + +/// A resolved inclusive byte range for one representation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HttpByteRange { + start: u64, + end: u64, + length: u64, + total_size: u64, +} + +impl HttpByteRange { + /// Creates a resolved byte range and validates it against the representation length. + pub fn new(start: u64, end: u64, total_size: u64) -> Result { + if total_size == 0 { + return Err(HttpRangeError::EmptyRepresentation); + } + if start > end || end >= total_size { + return Err(HttpRangeError::Unsatisfiable); + } + Ok(Self { + start, + end, + length: end - start + 1, + total_size, + }) + } + + #[must_use] + pub const fn start(self) -> u64 { + self.start + } + + #[must_use] + pub const fn end(self) -> u64 { + self.end + } + + #[must_use] + pub const fn length(self) -> u64 { + self.length + } + + #[must_use] + pub const fn total_size(self) -> u64 { + self.total_size + } + + /// Renders the value required by a successful `Content-Range` response header. + #[must_use] + pub fn content_range_header(self) -> String { + format!("bytes {}-{}/{}", self.start, self.end, self.total_size) + } +} + +/// Stable failure categories for a single byte-range request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum HttpRangeError { + #[error("range header must use the bytes unit")] + UnsupportedUnit, + #[error("multiple range requests are not supported")] + MultipleRangesUnsupported, + #[error("range header is malformed")] + Malformed, + #[error("range bound must be a valid unsigned integer")] + InvalidNumber, + #[error("range cannot be requested for an empty representation")] + EmptyRepresentation, + #[error("range is not satisfiable for the current representation")] + Unsatisfiable, +} + +/// Parses and resolves one RFC byte-range specifier against a representation length. +/// +/// Multiple ranges are reported separately so callers can choose whether to reject them or +/// implement multipart responses. End bounds beyond the representation are clamped as required +/// by HTTP range semantics. +pub fn parse_single_byte_range( + raw: &str, + total_size: u64, +) -> Result { + let raw = raw.trim_start(); + let (unit, range) = raw.split_once('=').ok_or(HttpRangeError::UnsupportedUnit)?; + if !unit.eq_ignore_ascii_case("bytes") { + return Err(HttpRangeError::UnsupportedUnit); + } + if range.contains(',') { + return Err(HttpRangeError::MultipleRangesUnsupported); + } + + let (start_raw, end_raw) = range.split_once('-').ok_or(HttpRangeError::Malformed)?; + if start_raw.is_empty() && end_raw.is_empty() { + return Err(HttpRangeError::Malformed); + } + if total_size == 0 { + return Err(HttpRangeError::EmptyRepresentation); + } + + if start_raw.is_empty() { + let suffix_length = parse_bound(end_raw)?; + if suffix_length == 0 { + return Err(HttpRangeError::Unsatisfiable); + } + let length = suffix_length.min(total_size); + return HttpByteRange::new(total_size - length, total_size - 1, total_size); + } + + let start = parse_bound(start_raw)?; + if start >= total_size { + return Err(HttpRangeError::Unsatisfiable); + } + let end = if end_raw.is_empty() { + total_size - 1 + } else { + parse_bound(end_raw)? + }; + if end < start { + return Err(HttpRangeError::Unsatisfiable); + } + HttpByteRange::new(start, end.min(total_size - 1), total_size) +} + +fn parse_bound(value: &str) -> Result { + value + .parse::() + .map_err(|_| HttpRangeError::InvalidNumber) +} + +#[cfg(test)] +mod tests { + use super::{HttpByteRange, HttpRangeError, parse_single_byte_range}; + + #[test] + fn resolves_bounded_open_and_suffix_ranges() { + assert_eq!( + parse_single_byte_range("bytes=5-9", 20), + HttpByteRange::new(5, 9, 20) + ); + assert_eq!( + parse_single_byte_range("bytes=7-", 20), + HttpByteRange::new(7, 19, 20) + ); + assert_eq!( + parse_single_byte_range("bytes=-6", 20), + HttpByteRange::new(14, 19, 20) + ); + assert_eq!( + parse_single_byte_range("bytes=-50", 20), + HttpByteRange::new(0, 19, 20) + ); + assert_eq!( + parse_single_byte_range(" BYTES=0-1", 20), + HttpByteRange::new(0, 1, 20) + ); + } + + #[test] + fn clamps_end_beyond_the_representation() { + assert_eq!( + parse_single_byte_range("bytes=17-99", 20), + HttpByteRange::new(17, 19, 20) + ); + } + + #[test] + fn preserves_u64_boundaries_without_overflow() { + let total_size = u64::MAX; + let range = parse_single_byte_range("bytes=0-18446744073709551615", total_size) + .expect("maximum end should clamp safely"); + assert_eq!(range.start(), 0); + assert_eq!(range.end(), u64::MAX - 1); + assert_eq!(range.length(), u64::MAX); + assert_eq!(range.total_size(), total_size); + } + + #[test] + fn renders_content_range_and_exposes_bounds() { + let range = HttpByteRange::new(2, 6, 10).expect("valid range"); + assert_eq!(range.start(), 2); + assert_eq!(range.end(), 6); + assert_eq!(range.length(), 5); + assert_eq!(range.total_size(), 10); + assert_eq!(range.content_range_header(), "bytes 2-6/10"); + } + + #[test] + fn constructor_rejects_empty_inverted_and_out_of_bounds_ranges() { + assert_eq!( + HttpByteRange::new(0, 0, 0), + Err(HttpRangeError::EmptyRepresentation) + ); + assert_eq!( + HttpByteRange::new(5, 4, 10), + Err(HttpRangeError::Unsatisfiable) + ); + assert_eq!( + HttpByteRange::new(5, 10, 10), + Err(HttpRangeError::Unsatisfiable) + ); + } + + #[test] + fn classifies_every_rejected_range_shape() { + let cases = [ + ("items=0-1", HttpRangeError::UnsupportedUnit), + ("bytes=0-1,3-4", HttpRangeError::MultipleRangesUnsupported), + ("bytes=-", HttpRangeError::Malformed), + ("bytes=abc-", HttpRangeError::InvalidNumber), + ("bytes=-0", HttpRangeError::Unsatisfiable), + ("bytes=9-5", HttpRangeError::Unsatisfiable), + ("bytes=20-", HttpRangeError::Unsatisfiable), + ]; + for (raw, expected) in cases { + assert_eq!(parse_single_byte_range(raw, 20), Err(expected), "{raw}"); + } + assert_eq!( + parse_single_byte_range("bytes=0-0", 0), + Err(HttpRangeError::EmptyRepresentation) + ); + } +} diff --git a/crates/aster_forge_utils/src/lib.rs b/crates/aster_forge_utils/src/lib.rs index d249bcc..b717cba 100644 --- a/crates/aster_forge_utils/src/lib.rs +++ b/crates/aster_forge_utils/src/lib.rs @@ -1,9 +1,9 @@ //! Shared low-level utility helpers for Aster services. //! //! This crate holds small, dependency-light helpers that do not belong to a single domain module: -//! boolean-like string parsing, checked numeric conversions, path rendering helpers, loopback host -//! detection, UUID/token helpers, and RAII cleanup guards. The shared error type is intentionally -//! simple so callers can map it into richer product errors. +//! boolean-like string parsing, HTTP range/validator handling, checked numeric conversions, path +//! rendering helpers, loopback host detection, UUID/token helpers, and RAII cleanup guards. The +//! shared error type is intentionally simple so callers can map it into richer product errors. #![deny(clippy::cast_possible_truncation, clippy::cast_sign_loss)] #![cfg_attr( not(test), @@ -22,6 +22,7 @@ pub mod backoff; pub mod bool_like; pub mod fs; pub mod html; +pub mod http_range; pub mod http_validators; pub mod id; pub mod net; diff --git a/crates/aster_forge_webdav/AGENTS.md b/crates/aster_forge_webdav/AGENTS.md new file mode 100644 index 0000000..5a55915 --- /dev/null +++ b/crates/aster_forge_webdav/AGENTS.md @@ -0,0 +1,35 @@ +# AGENTS.md + +This file supplements [`../../AGENTS.md`](../../AGENTS.md) and applies only to `aster_forge_webdav`. See [`../../docs/crates/aster_forge_webdav.md`](../../docs/crates/aster_forge_webdav.md) for protocol ownership, backend ports, errors, and the test matrix. See [`../../docs/crates/aster_forge_xml.md`](../../docs/crates/aster_forge_xml.md) for the underlying XML contract. + +## Before Making Changes + +1. Read `Cargo.toml`, `src/lib.rs`, the target protocol module, and the corresponding `tests/*.rs` files. +2. For XML changes, first determine whether `aster_forge_xml` should provide a generic upstream primitive. Keep only DAV grammar, selection, and response composition here. +3. Use downstream projects only to validate adapters. Authentication, permissions, workspace scope, storage, quota, persistence, and audit remain product-owned. + +## Ownership Boundaries + +- This crate owns the WebDAV and DeltaV protocol engine: path, header, and body parsing; preconditions; LOCK; properties; COPY/MOVE/DELETE/PUT/GET/HEAD planning; response grammar; backend ports; events; and the optional Actix adapter. +- `aster_forge_xml` owns bounded parsing, streaming, writing, safety limits, and namespace primitives. This crate owns DAV XML semantics. +- Products own Basic account authentication, rate limiting, principals and workspace permissions, file/blob/quota/storage policy, dead-property and lock persistence, business transactions, audit, and notifications. + +## Change Constraints + +- The transport-neutral core must not depend on Actix. The Actix layer performs explicit conversions for HTTP types, streams, and bodies. +- `DavBackendErrorKind` compresses only classifications required by the protocol. Keep detailed product errors in product logs and never leak product envelopes. +- Follow protocol contracts for path percent-decoding, dot segments, mount escape, same-origin `Destination`, `If`, ETag and lock-token handling, and `Depth` and `Overwrite` precedence. +- Decide empty, bounded, streaming, or unused body policy before reading. Bound XML size and depth, and never buffer an upload stream as one complete body. +- Keep multi-resource mutation results typed across 207/201/204 and partial failures; do not infer status from strings. +- Handle unknown XML extensions according to each method's grammar. Reject DTD and ENTITY, QName or namespace conflicts, and duplicated mutually exclusive controls. +- Backend traits expose only protocol-required ports. Do not absorb product repositories, authentication context, or storage-driver details. + +## Validation + +```bash +cargo test -p aster_forge_webdav +cargo test -p aster_forge_webdav --features actix +cargo clippy -p aster_forge_webdav --all-targets --all-features -- -D warnings +``` + +Run the relevant matrix for path escape, header grammar, conditional precedence, ranges, LOCK and `If`, property XML, partial failures, and Actix body policy. Run `aster_forge_xml` tests for XML primitives, then validate complete compatibility in product Litmus, rclone, curl, or cadaver tests. diff --git a/crates/aster_forge_webdav/Cargo.toml b/crates/aster_forge_webdav/Cargo.toml new file mode 100644 index 0000000..a18365f --- /dev/null +++ b/crates/aster_forge_webdav/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "aster_forge_webdav" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish = false + +[features] +default = [] +actix = ["dep:actix-web"] + +[dependencies] +actix-web = { workspace = true, optional = true } +aster_forge_utils = { path = "../aster_forge_utils" } +aster_forge_xml = { path = "../aster_forge_xml" } +async-trait.workspace = true +bytes.workspace = true +chrono = { workspace = true, features = ["clock"] } +futures.workspace = true +http.workspace = true +percent-encoding.workspace = true +thiserror.workspace = true diff --git a/crates/aster_forge_webdav/src/actix.rs b/crates/aster_forge_webdav/src/actix.rs new file mode 100644 index 0000000..db7451a --- /dev/null +++ b/crates/aster_forge_webdav/src/actix.rs @@ -0,0 +1,233 @@ +//! Optional Actix transport adapter for the WebDAV protocol model. + +use actix_web::http::{StatusCode as ActixStatusCode, header as actix_header}; +use actix_web::{HttpRequest, HttpResponse}; +use futures::StreamExt; +use http::{HeaderMap, HeaderName, HeaderValue, Uri}; + +use crate::protocol::DavProtocolError; +use crate::{ + DavBodyError, DavBodyPolicy, DavFileSystem, DavIfEvaluationError, DavLockSystem, DavMethod, + DavPath, DavPrecondition, DavRequestHead, DavRequestOrigin, DavResponse, DavResponseBody, + IfHeader, +}; + +/// Request body prepared according to the selected WebDAV method contract. +pub enum DavPreparedBody { + None, + Xml(Vec), +} + +impl DavPreparedBody { + /// Returns the collected XML bytes, or an empty slice for bodyless methods. + #[must_use] + pub fn xml(&self) -> &[u8] { + match self { + Self::None => &[], + Self::Xml(body) => body, + } + } +} + +/// Parses an Actix request into the transport-neutral request head. +pub fn request_head( + request: &HttpRequest, + mount_path: &str, +) -> Result, DavProtocolError> { + let Some(method) = DavMethod::from_name(request.method().as_str()) else { + return Ok(None); + }; + let uri: Uri = request + .uri() + .to_string() + .parse() + .map_err(|_| DavProtocolError::bad_request("Invalid request URI"))?; + let headers = convert_header_map(request.headers())?; + let connection = request.connection_info(); + let origin = DavRequestOrigin { + scheme: connection.scheme().to_string(), + host: connection.host().to_string(), + }; + DavRequestHead::parse(method, &uri, &headers, mount_path, &origin).map(Some) +} + +/// Converts a transport-neutral response into an Actix response. +pub fn into_response(response: DavResponse) -> HttpResponse { + let status = ActixStatusCode::from_u16(response.status.as_u16()) + .unwrap_or(ActixStatusCode::INTERNAL_SERVER_ERROR); + let mut builder = HttpResponse::build(status); + for (name, value) in &response.headers { + let name = actix_header::HeaderName::from_bytes(name.as_str().as_bytes()); + let value = actix_header::HeaderValue::from_bytes(value.as_bytes()); + if let (Ok(name), Ok(value)) = (name, value) { + builder.insert_header((name, value)); + } + } + match response.body { + DavResponseBody::Empty => builder.finish(), + DavResponseBody::Bytes(body) => builder.body(body), + DavResponseBody::Stream(stream) => { + let stream = stream.map(|item| { + item.map_err(|error| actix_web::error::ErrorInternalServerError(error.to_string())) + }); + builder.streaming(stream) + } + } +} + +/// Maps a transport-neutral protocol error into its Actix response. +#[must_use] +pub fn protocol_error_response(error: DavProtocolError) -> HttpResponse { + into_response(crate::protocol_error_response(&error)) +} + +/// Copies Actix headers into the transport-neutral map and maps malformed input to a response. +pub fn converted_headers(source: &actix_header::HeaderMap) -> Result { + convert_header_map(source).map_err(protocol_error_response) +} + +/// Resolves and enforces a parsed WebDAV `If` header through the canonical backend ports. +pub async fn enforce_if_header_with_backends( + if_header: Option<&IfHeader>, + filesystem: &dyn DavFileSystem, + lock_system: &dyn DavLockSystem, + request_path: &DavPath, + prefix: &str, + request_scheme: &str, + request_host: &str, +) -> Result<(), HttpResponse> { + match crate::enforce_if_header_with_backends( + if_header, + filesystem, + lock_system, + request_path, + prefix, + request_scheme, + request_host, + ) + .await + { + Ok(()) => Ok(()), + Err(DavIfEvaluationError::Protocol(error)) => Err(protocol_error_response(error)), + Err(DavIfEvaluationError::Backend(error)) => { + Err(into_response(crate::backend_error_response(&error))) + } + } +} + +/// Enforces resource lock submission and maps the protocol response to Actix. +pub async fn enforce_unlocked( + lock_system: &dyn DavLockSystem, + path: &DavPath, + deep: bool, + prefix: &str, + if_header: Option<&IfHeader>, + request_scheme: &str, + request_host: &str, +) -> Result<(), HttpResponse> { + crate::enforce_unlocked( + lock_system, + path, + deep, + prefix, + if_header, + request_scheme, + request_host, + ) + .await + .map_err(into_response) +} + +/// Enforces lock submission for the canonical parent and maps the response to Actix. +pub async fn enforce_parent_unlocked( + lock_system: &dyn DavLockSystem, + path: &DavPath, + prefix: &str, + if_header: Option<&IfHeader>, + request_scheme: &str, + request_host: &str, +) -> Result<(), HttpResponse> { + crate::enforce_parent_unlocked( + lock_system, + path, + prefix, + if_header, + request_scheme, + request_host, + ) + .await + .map_err(into_response) +} + +/// Evaluates HTTP ETag preconditions from Actix headers and maps protocol failures to a response. +pub fn evaluate_http_etag_preconditions( + headers: &actix_header::HeaderMap, + resource_exists: bool, + current_etag: Option<&str>, + safe_method: bool, +) -> Result { + let headers = converted_headers(headers)?; + crate::evaluate_http_etag_preconditions(&headers, resource_exists, current_etag, safe_method) + .map_err(protocol_error_response) +} + +/// Copies Actix header types into the transport-neutral `http` 1.x map. +pub fn convert_header_map(source: &actix_header::HeaderMap) -> Result { + let mut headers = HeaderMap::with_capacity(source.len()); + for (name, value) in source { + let name = HeaderName::from_bytes(name.as_str().as_bytes()) + .map_err(|_| DavProtocolError::bad_request("Invalid request header"))?; + let value = HeaderValue::from_bytes(value.as_bytes()) + .map_err(|_| DavProtocolError::bad_request("Invalid request header"))?; + headers.append(name, value); + } + Ok(headers) +} + +/// Rejects the first non-empty request body chunk without buffering the remaining payload. +pub async fn ensure_empty_body(payload: &mut actix_web::web::Payload) -> Result<(), DavBodyError> { + while let Some(chunk) = payload.next().await { + let chunk = chunk.map_err(|_| DavBodyError::ReadFailed)?; + if !chunk.is_empty() { + return Err(DavBodyError::BodyNotAllowed); + } + } + Ok(()) +} + +/// Collects a bounded XML request body for grammar parsing by the protocol layer. +pub async fn collect_bounded_xml_body( + payload: &mut actix_web::web::Payload, + maximum: usize, +) -> Result, DavBodyError> { + let mut body = Vec::with_capacity(maximum.min(4096)); + while let Some(chunk) = payload.next().await { + let chunk = chunk.map_err(|_| DavBodyError::ReadFailed)?; + let next_len = body + .len() + .checked_add(chunk.len()) + .ok_or(DavBodyError::XmlTooLarge)?; + if next_len > maximum { + return Err(DavBodyError::XmlTooLarge); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +/// Applies the method-owned body policy while leaving streaming PUT bodies untouched. +pub async fn prepare_request_body( + method: DavMethod, + payload: &mut actix_web::web::Payload, + xml_limit: usize, +) -> Result { + match method.body_policy() { + DavBodyPolicy::Empty => ensure_empty_body(payload) + .await + .map(|()| DavPreparedBody::None), + DavBodyPolicy::BoundedXml => collect_bounded_xml_body(payload, xml_limit) + .await + .map(DavPreparedBody::Xml), + DavBodyPolicy::Stream | DavBodyPolicy::Unused => Ok(DavPreparedBody::None), + } +} diff --git a/crates/aster_forge_webdav/src/backend.rs b/crates/aster_forge_webdav/src/backend.rs new file mode 100644 index 0000000..e478d65 --- /dev/null +++ b/crates/aster_forge_webdav/src/backend.rs @@ -0,0 +1,328 @@ +//! Product adapter ports consumed by the WebDAV protocol layer. + +use std::collections::HashMap; +use std::future::Future; +use std::io::SeekFrom; +use std::pin::Pin; +use std::time::{Duration, SystemTime}; + +use async_trait::async_trait; +use bytes::{Buf, Bytes}; +use futures::Stream; +use http::StatusCode; + +use crate::{DavPath, DavXmlElement}; + +/// Stream used for product-independent WebDAV content transfer. +pub type DavContentStream = + Pin> + Send + 'static>>; + +/// Stable backend failure categories mapped by the protocol layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavBackendErrorKind { + NotFound, + Forbidden, + Conflict, + AlreadyExists, + InsufficientStorage, + PayloadTooLarge, + Locked, + InvalidInput, + Unsupported, + Internal, +} + +/// Product-neutral failure returned by a product adapter. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("WebDAV backend operation failed: {kind:?}")] +pub struct DavBackendError { + pub kind: DavBackendErrorKind, +} + +impl DavBackendError { + #[must_use] + pub const fn new(kind: DavBackendErrorKind) -> Self { + Self { kind } + } +} + +/// Low-level file-system failure exposed by the product adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum FsError { + #[error("not found")] + NotFound, + #[error("forbidden")] + Forbidden, + #[error("general failure")] + GeneralFailure, + #[error("already exists")] + Exists, + #[error("insufficient storage")] + InsufficientStorage, + #[error("too large")] + TooLarge, + #[error("bad request")] + BadRequest, +} + +impl From for DavBackendError { + fn from(error: FsError) -> Self { + let kind = match error { + FsError::NotFound => DavBackendErrorKind::NotFound, + FsError::Forbidden => DavBackendErrorKind::Forbidden, + FsError::GeneralFailure => DavBackendErrorKind::Internal, + FsError::Exists => DavBackendErrorKind::AlreadyExists, + FsError::InsufficientStorage => DavBackendErrorKind::InsufficientStorage, + FsError::TooLarge => DavBackendErrorKind::PayloadTooLarge, + FsError::BadRequest => DavBackendErrorKind::InvalidInput, + }; + Self::new(kind) + } +} + +pub type FsResult = Result; +pub type FsFuture<'a, T> = Pin> + Send + 'a>>; +pub type FsStream = Pin> + Send>>; + +/// WebDAV resource type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DavResourceKind { + File, + Collection, +} + +/// Protocol-visible state used to evaluate one resource referenced by an `If` header. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DavIfResourceState { + pub etag: Option, + pub lock_tokens: Vec, +} + +/// Product adapter used while evaluating WebDAV `If` conditions. +#[async_trait] +pub trait DavIfStateResolver: Send + Sync { + async fn resolve_if_state(&self, path: &DavPath) + -> Result; +} + +/// Metadata loading mode for directory entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadDirMeta { + Data, +} + +/// File open contract selected by the protocol planner. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OpenOptions { + pub read: bool, + pub write: bool, + pub append: bool, + pub truncate: bool, + pub create: bool, + pub create_new: bool, + pub size: Option, + pub checksum: Option, +} + +impl OpenOptions { + #[must_use] + pub fn read() -> Self { + Self { + read: true, + ..Self::default() + } + } + + #[must_use] + pub fn write() -> Self { + Self { + write: true, + ..Self::default() + } + } +} + +/// Protocol-visible resource metadata supplied by the product adapter. +pub trait DavMetaData: Send + Sync { + fn len(&self) -> u64; + fn modified(&self) -> FsResult; + fn is_dir(&self) -> bool; + fn etag(&self) -> Option; + fn content_type(&self) -> Option<&str> { + None + } + fn created(&self) -> FsResult; + fn is_empty(&self) -> bool { + self.len() == 0 + } + fn is_file(&self) -> bool { + !self.is_dir() + } +} + +/// One directory entry returned by a product adapter. +pub trait DavDirEntry: Send { + fn name(&self) -> Vec; + fn metadata<'a>(&'a self) -> FsFuture<'a, Box>; +} + +/// Open file handle supplied by a product adapter. +pub trait DavFile: Send { + fn metadata<'a>(&'a mut self) -> FsFuture<'a, Box>; + fn read_bytes(&mut self, count: usize) -> FsFuture<'_, Bytes>; + fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()>; + fn write_buf(&mut self, buf: Box) -> FsFuture<'_, ()>; + fn seek(&mut self, pos: SeekFrom) -> FsFuture<'_, u64>; + fn flush(&mut self) -> FsFuture<'_, ()>; +} + +/// Stored dead property exchanged with the product adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavProp { + pub name: String, + pub prefix: Option, + pub namespace: Option, + pub xml: Option>, +} + +/// Canonical resource and dead-property backend port. +pub trait DavFileSystem: Send + Sync { + fn open<'a>( + &'a self, + path: &'a DavPath, + options: OpenOptions, + ) -> FsFuture<'a, Box>; + fn read_dir<'a>( + &'a self, + path: &'a DavPath, + meta: ReadDirMeta, + ) -> FsFuture<'a, FsStream>>; + fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box>; + fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()>; + fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()>; + fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()>; + fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()>; + fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()>; + + fn get_quota(&self) -> FsFuture<'_, (u64, Option)> { + Box::pin(async { Ok((0, None)) }) + } + + fn have_props<'a>( + &'a self, + _path: &'a DavPath, + ) -> Pin + Send + 'a>> { + Box::pin(async { false }) + } + + fn get_props<'a>( + &'a self, + _path: &'a DavPath, + _do_content: bool, + ) -> FsFuture<'a, Vec> { + Box::pin(async { Ok(Vec::new()) }) + } + + /// Serial fallback that calls [`DavFileSystem::get_props`] once per path. + /// + /// Production adapters should override this method when the persistence layer supports a + /// genuine batch query. + fn get_props_many<'a>( + &'a self, + paths: &'a [DavPath], + do_content: bool, + ) -> FsFuture<'a, HashMap>> { + Box::pin(async move { + let mut result = HashMap::with_capacity(paths.len()); + for path in paths { + result.insert(path.clone(), self.get_props(path, do_content).await?); + } + Ok(result) + }) + } + + fn patch_props<'a>( + &'a self, + _path: &'a DavPath, + _patches: Vec<(bool, DavProp)>, + ) -> FsFuture<'a, Vec<(StatusCode, DavProp)>> { + Box::pin(async { Ok(Vec::new()) }) + } +} + +/// Protocol-visible lock state persisted by the product adapter. +#[derive(Debug, Clone)] +pub struct DavLock { + pub token: String, + pub path: Box, + pub principal: Option, + pub owner: Option>, + pub timeout_at: Option, + pub timeout: Option, + pub shared: bool, + pub deep: bool, +} + +pub type LsFuture<'a, T> = Pin + Send + 'a>>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavLockPreflightError { + LimitExceeded, + GeneralFailure, +} + +#[derive(Debug, Clone)] +pub enum DavLockError { + Conflict(Box), + TokenMismatch, + LimitExceeded, + Backend, +} + +/// Canonical lock persistence and conflict backend port. +pub trait DavLockSystem: Send + Sync { + fn prepare_lock(&self, _path: &DavPath) -> LsFuture<'_, Result<(), DavLockPreflightError>> { + Box::pin(async { Ok(()) }) + } + + fn lock( + &self, + path: &DavPath, + principal: Option<&str>, + owner: Option<&DavXmlElement>, + timeout: Option, + shared: bool, + deep: bool, + ) -> LsFuture<'_, Result>; + + fn unlock(&self, path: &DavPath, token: &str) -> LsFuture<'_, Result<(), DavLockError>>; + fn refresh( + &self, + path: &DavPath, + token: &str, + timeout: Option, + ) -> LsFuture<'_, Result>; + fn check( + &self, + path: &DavPath, + principal: Option<&str>, + ignore_principal: bool, + deep: bool, + submitted_tokens: &[String], + ) -> LsFuture<'_, Result<(), DavLock>>; + fn discover(&self, path: &DavPath) -> LsFuture<'_, Vec>; + fn discover_many<'a>( + &'a self, + paths: &'a [DavPath], + ) -> LsFuture<'a, HashMap>> { + Box::pin(async move { + let mut result = HashMap::with_capacity(paths.len()); + for path in paths { + result.insert(path.clone(), self.discover(path).await); + } + result + }) + } + fn conflicting_locks(&self, path: &DavPath, deep: bool) -> LsFuture<'_, Vec>; + fn delete(&self, path: &DavPath) -> LsFuture<'_, Result<(), DavLockError>>; +} diff --git a/crates/aster_forge_webdav/src/deltav.rs b/crates/aster_forge_webdav/src/deltav.rs new file mode 100644 index 0000000..75ff89e --- /dev/null +++ b/crates/aster_forge_webdav/src/deltav.rs @@ -0,0 +1,113 @@ +//! Product-neutral DeltaV request planning and response composition. + +use http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use http::{HeaderValue, StatusCode}; + +use crate::{ + DavErrorCondition, DavResourceKind, DavResponse, DavVersionXml, DavXmlError, dav_error_element, + dav_version_multistatus_element, parse_report_root, +}; + +/// Failure while selecting the supported DeltaV REPORT grammar. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DavVersionTreeReportError { + /// The REPORT body is not safe, well-formed WebDAV XML. + #[error(transparent)] + Xml(#[from] DavXmlError), + /// The REPORT root is not the supported `DAV:version-tree` report. + #[error("unsupported DeltaV REPORT type: {name}")] + Unsupported { name: String }, +} + +/// Validates that a REPORT request selects `DAV:version-tree`. +pub fn validate_version_tree_report(body: &[u8]) -> Result<(), DavVersionTreeReportError> { + let root = parse_report_root(body)?; + if root.name == "version-tree" && root.namespace.as_deref() == Some("DAV:") { + Ok(()) + } else { + Err(DavVersionTreeReportError::Unsupported { name: root.name }) + } +} + +/// Builds the protocol response for a REPORT grammar selection failure. +pub fn version_tree_report_error_response( + error: &DavVersionTreeReportError, +) -> Result { + match error { + DavVersionTreeReportError::Xml(DavXmlError::ExternalEntity) => xml_response( + StatusCode::FORBIDDEN, + dav_error_element(&DavErrorCondition::NoExternalEntities), + ), + DavVersionTreeReportError::Xml(DavXmlError::TooLarge) => Ok(text_response( + StatusCode::PAYLOAD_TOO_LARGE, + "WebDAV XML body too large", + )), + DavVersionTreeReportError::Xml( + DavXmlError::TooDeep | DavXmlError::Malformed | DavXmlError::InvalidGrammar, + ) => Ok(text_response(StatusCode::BAD_REQUEST, "Invalid XML body")), + DavVersionTreeReportError::Unsupported { name } => Ok(text_response( + StatusCode::NOT_IMPLEMENTED, + format!("Unsupported REPORT type: {name}"), + )), + } +} + +/// Builds the file-only conflict response for a version-tree REPORT. +#[must_use] +pub fn version_tree_non_file_response() -> DavResponse { + text_response( + StatusCode::CONFLICT, + "Version history is only available for files", + ) +} + +/// Builds a complete 207 DeltaV version-tree response. +pub fn version_tree_response(versions: Vec) -> Result { + xml_response( + StatusCode::MULTI_STATUS, + dav_version_multistatus_element(versions), + ) +} + +/// Selects the VERSION-CONTROL response for the resolved resource kind. +#[must_use] +pub fn version_control_response(kind: DavResourceKind) -> DavResponse { + match kind { + DavResourceKind::File => text_response(StatusCode::OK, "Already under version control"), + DavResourceKind::Collection => text_response( + StatusCode::METHOD_NOT_ALLOWED, + "Only files support version control", + ), + } +} + +fn text_response(status: StatusCode, body: impl Into) -> DavResponse { + let mut response = DavResponse::bytes(status, body.into()); + response.headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + if status.is_client_error() || status.is_server_error() { + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + } + response +} + +fn xml_response( + status: StatusCode, + root: crate::DavXmlElement, +) -> Result { + let mut response = DavResponse::bytes(status, root.to_bytes()?); + response.headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/xml; charset=utf-8"), + ); + if status.is_client_error() || status.is_server_error() { + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + } + Ok(response) +} diff --git a/crates/aster_forge_webdav/src/event.rs b/crates/aster_forge_webdav/src/event.rs new file mode 100644 index 0000000..2f5ae80 --- /dev/null +++ b/crates/aster_forge_webdav/src/event.rs @@ -0,0 +1,115 @@ +//! Observable WebDAV operation events. + +use std::time::Duration; + +use crate::{DavBackendErrorKind, DavPath, DavRequestHead}; + +/// Protocol operations exposed to event observers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavOperation { + Options, + Propfind, + Proppatch, + Get, + Head, + Put, + Mkcol, + Delete, + Copy, + Move, + Lock, + Unlock, + Report, + VersionControl, +} + +/// Protocol result exposed to observers without credentials, bodies, or lock tokens. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavEventOutcome { + Succeeded { + /// HTTP/WebDAV response status without leaking a transport crate version. + status: u16, + }, + Failed { + /// HTTP/WebDAV response status without leaking a transport crate version. + status: u16, + backend_error: Option, + }, +} + +impl DavEventOutcome { + /// Classifies a completed response. Informational, success, and redirection statuses are + /// successful protocol outcomes; client and server errors are failures. + #[must_use] + pub const fn from_status(status: u16, backend_error: Option) -> Self { + if status < 400 { + Self::Succeeded { status } + } else { + Self::Failed { + status, + backend_error, + } + } + } + + /// Returns the completed HTTP/WebDAV status. + #[must_use] + pub const fn status(self) -> u16 { + match self { + Self::Succeeded { status } | Self::Failed { status, .. } => status, + } + } +} + +/// One completed WebDAV operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavEvent { + pub request_id: Option, + pub operation: DavOperation, + pub source: DavPath, + pub destination: Option, + pub outcome: DavEventOutcome, + pub elapsed: Duration, +} + +impl DavEvent { + /// Builds the transport-neutral event for one completed request. + /// + /// Only protocol routing data is copied from the request head. Conditional headers, + /// credentials, request bodies, and lock tokens are deliberately excluded. + #[must_use] + pub fn completed( + request_head: &DavRequestHead, + status: u16, + elapsed: Duration, + backend_error: Option, + ) -> Self { + Self { + request_id: None, + operation: request_head.method.operation(), + source: request_head.target.clone(), + destination: request_head + .destination + .as_ref() + .map(|destination| destination.path.clone()), + outcome: DavEventOutcome::from_status(status, backend_error), + elapsed, + } + } +} + +/// Non-authoritative observer for audit adapters, metrics, tracing, and notifications. +/// +/// Required mutations, quota updates, lock persistence, and cache correctness must complete in +/// the synchronous backend operation before this observer is called. +pub trait DavEventSink: Send + Sync { + fn publish(&self, event: &DavEvent); +} + +/// Event sink used when a product does not need protocol-level observation. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoopDavEventSink; + +impl DavEventSink for NoopDavEventSink { + fn publish(&self, _event: &DavEvent) {} +} diff --git a/crates/aster_forge_webdav/src/lib.rs b/crates/aster_forge_webdav/src/lib.rs new file mode 100644 index 0000000..036fa98 --- /dev/null +++ b/crates/aster_forge_webdav/src/lib.rs @@ -0,0 +1,102 @@ +//! Product-neutral WebDAV protocol engine contracts for Aster services. +//! +//! This crate owns WebDAV paths, request parsing, protocol preconditions, backend ports, +//! response models, and observable operation events. Product repositories own authentication, +//! authorization, workspace scope, persistence, storage policy, quota, and audit semantics. +#![deny(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +#![cfg_attr( + not(test), + deny( + clippy::unwrap_used, + clippy::unreachable, + clippy::expect_used, + clippy::panic, + clippy::unimplemented, + clippy::todo + ) +)] + +#[cfg(feature = "actix")] +pub mod actix; +pub mod backend; +pub mod deltav; +pub mod event; +pub mod lock; +pub mod path; +pub mod property; +pub mod protocol; +pub mod put; +pub mod request; +pub mod resource; +pub mod response; +pub mod xml; +pub mod xml_response; + +pub use backend::{ + DavBackendError, DavBackendErrorKind, DavContentStream, DavDirEntry, DavFile, DavFileSystem, + DavIfResourceState, DavIfStateResolver, DavLock, DavLockError, DavLockPreflightError, + DavLockSystem, DavMetaData, DavProp, DavResourceKind, FsError, FsFuture, FsResult, FsStream, + LsFuture, OpenOptions, ReadDirMeta, +}; +pub use deltav::{ + DavVersionTreeReportError, validate_version_tree_report, version_control_response, + version_tree_non_file_response, version_tree_report_error_response, version_tree_response, +}; +pub use event::{DavEvent, DavEventOutcome, DavEventSink, DavOperation, NoopDavEventSink}; +pub use lock::{ + DavLockPlan, DavLockPlanError, enforce_parent_unlocked, enforce_unlocked, + ensure_lock_target_exists, lock_acquire_success_response, lock_conflict_response, + lock_discovery_element, lock_limit_response, lock_refresh_success_response, + lock_xml_error_response, plan_lock_request, unlock_success_response, + unlock_token_mismatch_response, unsubmitted_lock_conflicts, +}; +pub use path::{ + DavPath, DavPathError, child_relative_path, decode_relative_path, display_name, encode_href, + href_for_dav_path, href_for_relative, parent_relative_path, +}; +pub use property::{ + DavProppatchAtomicPlan, build_propfind_item, build_proppatch_item, format_creation_date, + plan_atomic_proppatch, property_multistatus_response, propfind_finite_depth_response, + propfind_request_label, propfind_xml_error_response, proppatch_xml_error_response, +}; +pub use protocol::{ + DavIfEvaluationError, DavPrecondition, DavProtocolError, DavProtocolErrorKind, Depth, + Destination, IfHeader, IfResourceGroup, IfStateCondition, IfStateList, + destination_relative_path, enforce_if_header, enforce_if_header_with_backends, + evaluate_http_download_preconditions, evaluate_http_etag_preconditions, parse_copy_depth, + parse_delete_depth, parse_if_header, parse_lock_depth, parse_lock_timeout, + parse_lock_token_header, parse_move_depth, parse_overwrite, parse_propfind_depth, + submitted_lock_tokens, submitted_lock_tokens_for_path, +}; +pub use put::{ + DavPutPlan, DavPutPlanError, DavPutResourceState, DavPutResponseError, plan_put_request, + put_plan_error_response, put_success_response, +}; +pub use request::{DavBodyPolicy, DavMethod, DavRequestHead, DavRequestOrigin}; +pub use resource::{ + DavCopyMoveMethod, DavCopyMovePlan, DavMutationFailure, DavMutationPlanError, + DavMutationResponseError, collection_created_response, delete_success_response, + enforce_parent_collection, is_descendant_path, mutation_multistatus_response, + mutation_plan_error_response, mutation_success_response, plan_copy_move_request, + replace_relative_prefix, resource_identity_path, same_resource_path, + validate_collection_create_target, validate_delete_target, +}; +pub use response::{ + DAV_ALLOW_HEADER, DavBodyError, DavDownloadBody, DavDownloadPlan, DavDownloadPlanError, + DavResponse, DavResponseBody, backend_error_response, body_error_response, + method_not_allowed_response, options_response, plan_download_response, protocol_error_response, + range_not_satisfiable_response, +}; +pub use xml::{ + DavLockRequestBody, DavPropertyPatchRequest, DavPropertyPatchValue, DavPropfindRequest, + DavRequestedProperty, DavXmlElement, DavXmlError, DavXmlNode, parse_lock_request, + parse_propfind_request, parse_proppatch_request, parse_report_root, +}; +pub use xml_response::{ + DavErrorCondition, DavLockXml, DavMultiStatusItem, DavPropStat, DavVersionXml, + dav_dead_property_element, dav_element, dav_error_element, dav_lock_discovery_element, + dav_lock_response_element, dav_multistatus_element, dav_property_child_element, + dav_property_name_element, dav_property_text_element, dav_propstat_element, + dav_response_element, dav_status_element, dav_supported_lock_element, dav_text_element, + dav_version_multistatus_element, +}; diff --git a/crates/aster_forge_webdav/src/lock.rs b/crates/aster_forge_webdav/src/lock.rs new file mode 100644 index 0000000..8358578 --- /dev/null +++ b/crates/aster_forge_webdav/src/lock.rs @@ -0,0 +1,323 @@ +//! LOCK/UNLOCK protocol request planning and response composition. + +use std::time::Duration; + +use http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use http::{HeaderMap, HeaderValue, StatusCode}; + +use crate::DavLockSystem; +use crate::response::{no_store_empty_response, xml_request_error_response}; +use crate::{ + DavErrorCondition, DavFileSystem, DavLock, DavLockXml, DavPath, DavProtocolError, + DavRequestHead, DavResponse, DavXmlElement, DavXmlError, FsError, IfHeader, OpenOptions, + dav_error_element, dav_lock_discovery_element, dav_lock_response_element, href_for_dav_path, + parent_relative_path, parse_lock_request, parse_lock_timeout, protocol_error_response, + submitted_lock_tokens, +}; + +/// Backend operation selected from a LOCK request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DavLockPlan { + Acquire { + owner: Option, + timeout: Duration, + shared: bool, + deep: bool, + }, + Refresh { + token: String, + timeout: Duration, + }, +} + +/// Failure while parsing and selecting a LOCK operation. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DavLockPlanError { + #[error(transparent)] + Protocol(#[from] DavProtocolError), + #[error(transparent)] + Xml(#[from] DavXmlError), +} + +/// Rejects an operation when a conflicting lock token was not submitted for its lock root. +pub async fn enforce_unlocked( + lock_system: &dyn DavLockSystem, + path: &DavPath, + deep: bool, + prefix: &str, + if_header: Option<&IfHeader>, + request_scheme: &str, + request_host: &str, +) -> Result<(), DavResponse> { + if let Some(lock) = unsubmitted_lock_conflicts( + lock_system, + path, + deep, + prefix, + if_header, + request_scheme, + request_host, + ) + .await + .into_iter() + .next() + { + return Err(lock_conflict_response(prefix, &lock.path) + .unwrap_or_else(|_| DavResponse::empty(StatusCode::INTERNAL_SERVER_ERROR))); + } + Ok(()) +} + +/// Returns conflicting locks whose tokens were not submitted for their corresponding lock root. +pub async fn unsubmitted_lock_conflicts( + lock_system: &dyn DavLockSystem, + path: &DavPath, + deep: bool, + prefix: &str, + if_header: Option<&IfHeader>, + request_scheme: &str, + request_host: &str, +) -> Vec { + let mut conflicts = lock_system.conflicting_locks(path, deep).await; + conflicts.retain(|lock| { + let lock_href = href_for_dav_path(prefix, &lock.path); + let submitted_tokens = if_header.map_or_else(Vec::new, |if_header| { + submitted_lock_tokens(if_header, &lock_href, request_scheme, request_host) + }); + !submitted_tokens.iter().any(|token| token == &lock.token) + }); + conflicts +} + +/// Applies [`enforce_unlocked`] to the canonical parent of a mutation target. +pub async fn enforce_parent_unlocked( + lock_system: &dyn DavLockSystem, + path: &DavPath, + prefix: &str, + if_header: Option<&IfHeader>, + request_scheme: &str, + request_host: &str, +) -> Result<(), DavResponse> { + let Some(parent) = parent_relative_path(path.as_str()) else { + return Ok(()); + }; + let parent_path = DavPath::new(&parent).map_err(|_| { + protocol_error_response(&DavProtocolError::bad_request("Invalid request path")) + })?; + enforce_unlocked( + lock_system, + &parent_path, + false, + prefix, + if_header, + request_scheme, + request_host, + ) + .await +} + +/// Ensures that a LOCK target exists, creating an empty lock-null file when allowed. +/// +/// Existing resources are left untouched. A missing collection target remains missing because +/// creating its hierarchy is outside LOCK semantics. +pub async fn ensure_lock_target_exists( + filesystem: &dyn DavFileSystem, + path: &DavPath, +) -> Result { + match filesystem.metadata(path).await { + Ok(_) => Ok(true), + Err(FsError::NotFound) if !path.is_collection() => { + let mut file = filesystem + .open( + path, + OpenOptions { + write: true, + create: true, + truncate: true, + size: Some(0), + ..OpenOptions::default() + }, + ) + .await?; + file.flush().await?; + Ok(false) + } + Err(FsError::NotFound) => Err(FsError::NotFound), + Err(error) => Err(error), + } +} + +/// Selects lock acquisition or refresh and validates all protocol-owned inputs. +pub fn plan_lock_request( + headers: &HeaderMap, + body: &[u8], + request_head: &DavRequestHead, + prefix: &str, + maximum_timeout: Duration, +) -> Result { + let timeout = parse_lock_timeout(headers, maximum_timeout)?; + if body.is_empty() { + let request_href = href_for_dav_path(prefix, &request_head.target); + let tokens = request_head + .if_header + .as_ref() + .map_or_else(Vec::new, |if_header| { + submitted_lock_tokens( + if_header, + &request_href, + &request_head.origin.scheme, + &request_head.origin.host, + ) + }); + if tokens.len() != 1 { + return Err(DavProtocolError::bad_request("Invalid LOCK refresh token").into()); + } + return Ok(DavLockPlan::Refresh { + token: tokens[0].clone(), + timeout, + }); + } + + let request = parse_lock_request(body)?; + let depth = request_head + .depth + .ok_or_else(|| DavProtocolError::bad_request("LOCK Depth was not parsed"))?; + Ok(DavLockPlan::Acquire { + owner: request.owner, + timeout, + shared: request.shared, + deep: depth.is_infinity(), + }) +} + +fn lock_success_response( + lock: &DavLock, + status: StatusCode, + prefix: &str, + include_lock_token_header: bool, +) -> Result { + let body = dav_lock_response_element(&[DavLockXml { + token: lock.token.clone(), + owner: lock.owner.as_deref().cloned(), + timeout: lock.timeout, + shared: lock.shared, + deep: lock.deep, + root_href: href_for_dav_path(prefix, &lock.path), + }]) + .to_bytes()?; + let mut response = DavResponse::bytes(status, body); + response.headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/xml; charset=utf-8"), + ); + if include_lock_token_header { + let value = HeaderValue::from_str(&format!("<{}>", lock.token)) + .map_err(|_| DavXmlError::Malformed)?; + response.headers.insert("Lock-Token", value); + } + Ok(response) +} + +/// Builds the successful response for a LOCK refresh. +pub fn lock_refresh_success_response( + lock: &DavLock, + prefix: &str, +) -> Result { + lock_success_response(lock, StatusCode::OK, prefix, false) +} + +/// Builds the 200/201 response for a LOCK acquisition. +pub fn lock_acquire_success_response( + lock: &DavLock, + prefix: &str, + resource_existed: bool, +) -> Result { + lock_success_response( + lock, + if resource_existed { + StatusCode::OK + } else { + StatusCode::CREATED + }, + prefix, + true, + ) +} + +/// Builds the `DAV:lockdiscovery` property from backend lock values. +#[must_use] +pub fn lock_discovery_element(locks: &[DavLock], prefix: &str) -> DavXmlElement { + let locks = locks + .iter() + .map(|lock| DavLockXml { + token: lock.token.clone(), + owner: lock.owner.as_deref().cloned(), + timeout: lock.timeout, + shared: lock.shared, + deep: lock.deep, + root_href: href_for_dav_path(prefix, &lock.path), + }) + .collect::>(); + dav_lock_discovery_element(&locks) +} + +/// Builds a 423 response identifying the lock whose token must be submitted. +pub fn lock_conflict_response(prefix: &str, path: &DavPath) -> Result { + lock_condition_response( + StatusCode::LOCKED, + DavErrorCondition::LockTokenSubmitted { + href: href_for_dav_path(prefix, path), + }, + ) +} + +/// Builds the 409 response for an UNLOCK token that does not match the request URI. +pub fn unlock_token_mismatch_response() -> Result { + lock_condition_response( + StatusCode::CONFLICT, + DavErrorCondition::LockTokenMatchesRequestUri, + ) +} + +/// Builds the successful cache-safe UNLOCK response. +#[must_use] +pub fn unlock_success_response() -> DavResponse { + no_store_empty_response(StatusCode::NO_CONTENT) +} + +/// Builds the active-lock capacity response. +#[must_use] +pub fn lock_limit_response() -> DavResponse { + let mut response = DavResponse::bytes( + StatusCode::INSUFFICIENT_STORAGE, + "WebDAV active lock limit exceeded", + ); + response.headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// Maps LOCK XML failures to their protocol response. +pub fn lock_xml_error_response(error: DavXmlError) -> Result { + xml_request_error_response(error, "Invalid LOCK body") +} + +fn lock_condition_response( + status: StatusCode, + condition: DavErrorCondition, +) -> Result { + let mut response = DavResponse::bytes(status, dav_error_element(&condition).to_bytes()?); + response.headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/xml; charset=utf-8"), + ); + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + Ok(response) +} diff --git a/crates/aster_forge_webdav/src/path.rs b/crates/aster_forge_webdav/src/path.rs new file mode 100644 index 0000000..4a4d97f --- /dev/null +++ b/crates/aster_forge_webdav/src/path.rs @@ -0,0 +1,220 @@ +//! Canonical WebDAV path handling. + +use std::str; + +use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode}; + +const DAV_HREF_PATH_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}') + .add(b'&') + .add(b'\'') + .add(b'+') + .add(b'%'); + +/// A normalized path relative to a WebDAV mount. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DavPath { + canonical: String, +} + +/// Parses a mount-relative request path and returns its canonical decoded representation. +pub fn decode_relative_path(relative: &str) -> Result { + DavPath::new(relative) +} + +/// Percent-encodes a DAV href while preserving path separators. +#[must_use] +pub fn encode_href(path: &str) -> String { + utf8_percent_encode(path, DAV_HREF_PATH_SET).to_string() +} + +/// Builds an encoded href from a mount prefix and decoded relative path. +#[must_use] +pub fn href_for_relative(prefix: &str, relative: &str) -> String { + let href = if relative == "/" { + format!("{prefix}/") + } else { + format!("{prefix}{relative}") + }; + encode_href(&href) +} + +/// Builds an encoded href from a mount prefix and canonical DAV path. +#[must_use] +pub fn href_for_dav_path(prefix: &str, path: &DavPath) -> String { + href_for_relative(prefix, path.as_str()) +} + +/// Returns a child path with collection trailing-slash semantics. +pub fn child_relative_path( + parent: &str, + name: &[u8], + is_collection: bool, +) -> Result { + let name = str::from_utf8(name).map_err(|_| DavPathError::InvalidEncoding)?; + if name.contains(['/', '\\']) { + return Err(DavPathError::InvalidChildName); + } + let mut relative = if parent == "/" { + format!("/{name}") + } else if parent.ends_with('/') { + format!("{parent}{name}") + } else { + format!("{parent}/{name}") + }; + if is_collection && !relative.ends_with('/') { + relative.push('/'); + } + Ok(relative) +} + +/// Returns the canonical parent collection path. +#[must_use] +pub fn parent_relative_path(relative: &str) -> Option { + if relative == "/" { + return None; + } + let trimmed = relative.trim_end_matches('/'); + let mut segments = trimmed + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + if segments.len() <= 1 { + return Some("/".to_string()); + } + segments.pop(); + Some(format!("/{}/", segments.join("/"))) +} + +/// Returns the final decoded segment for DAV display-name generation. +#[must_use] +pub fn display_name(relative: &str) -> &str { + if relative == "/" { + "" + } else { + relative + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or("") + } +} + +/// Errors produced while canonicalizing a WebDAV path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DavPathError { + /// The path contains malformed percent encoding. + #[error("invalid WebDAV path encoding")] + InvalidEncoding, + /// Dot-segment normalization would escape the WebDAV mount root. + #[error("WebDAV path escapes the mount root")] + PathEscape, + /// A backend child name contains a path separator. + #[error("WebDAV child name contains a path separator")] + InvalidChildName, +} + +impl DavPath { + /// Percent-decodes and canonicalizes a path without allowing root escape. + pub fn new(path: &str) -> Result { + let encoded = ensure_leading_slash(path); + if contains_encoded_path_separator(&encoded) { + return Err(DavPathError::InvalidEncoding); + } + let decoded = percent_decode_str(&encoded) + .decode_utf8() + .map_err(|_| DavPathError::InvalidEncoding)?; + let canonical = clean_decoded_path(&decoded)?; + Ok(Self { canonical }) + } + + /// Returns the WebDAV mount root. + #[must_use] + pub fn root() -> Self { + Self { + canonical: "/".to_string(), + } + } + + /// Returns the decoded canonical path bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.canonical.as_bytes() + } + + /// Returns the decoded canonical UTF-8 path. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } + + /// Returns whether the path denotes a collection alias. + #[must_use] + pub fn is_collection(&self) -> bool { + self.canonical == "/" || self.canonical.ends_with('/') + } +} + +fn contains_encoded_path_separator(path: &str) -> bool { + path.as_bytes().windows(3).any(|window| { + let high = window[1].to_ascii_lowercase(); + let low = window[2].to_ascii_lowercase(); + window[0] == b'%' && matches!((high, low), (b'2', b'f') | (b'5', b'c')) + }) +} + +fn ensure_leading_slash(path: &str) -> String { + if path.is_empty() || path == "/" { + return "/".to_string(); + } + + let mut normalized = path.to_string(); + if !normalized.starts_with('/') { + normalized.insert(0, '/'); + } + normalized +} + +fn clean_decoded_path(path: &str) -> Result { + let mut segments = Vec::new(); + let mut is_collection = false; + + for (index, segment) in path.split('/').enumerate() { + match segment { + "" => { + if index > 0 { + is_collection = true; + } + } + "." => is_collection = true, + ".." => { + if segments.pop().is_none() { + return Err(DavPathError::PathEscape); + } + is_collection = true; + } + segment => { + segments.push(segment); + is_collection = false; + } + } + } + + if segments.is_empty() { + return Ok("/".to_string()); + } + + let mut cleaned = format!("/{}", segments.join("/")); + if is_collection && !cleaned.ends_with('/') { + cleaned.push('/'); + } + Ok(cleaned) +} diff --git a/crates/aster_forge_webdav/src/property.rs b/crates/aster_forge_webdav/src/property.rs new file mode 100644 index 0000000..e78a04f --- /dev/null +++ b/crates/aster_forge_webdav/src/property.rs @@ -0,0 +1,204 @@ +//! WebDAV property selection and propstat composition. + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::SystemTime; + +use http::StatusCode; + +use crate::response::{xml_document_response, xml_request_error_response}; +use crate::{ + DavErrorCondition, DavMultiStatusItem, DavPropStat, DavPropfindRequest, DavRequestedProperty, + DavResponse, DavXmlElement, DavXmlError, dav_error_element, dav_multistatus_element, + dav_property_name_element, +}; + +type PropertyKey = (String, Option); + +/// Formats a DAV `creationdate` value as RFC 3339 UTC text. +#[must_use] +pub fn format_creation_date(time: SystemTime) -> String { + chrono::DateTime::::from(time).to_rfc3339() +} + +/// Atomic PROPPATCH execution decision and per-property protocol statuses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavProppatchAtomicPlan { + /// Whether the product adapter should persist every requested property mutation. + pub apply: bool, + /// Status assigned to each property in request order. + pub statuses: Vec, +} + +/// Selects the RFC 4918 atomic PROPPATCH statuses from product-owned protection decisions. +/// +/// When any property is protected, that property receives `403` and every otherwise valid +/// property receives `424`. If none are protected, every property receives `200` and the adapter +/// may apply the entire transaction. +pub fn plan_atomic_proppatch(protected: impl IntoIterator) -> DavProppatchAtomicPlan { + let protected = protected.into_iter().collect::>(); + let has_protected = protected.iter().any(|protected| *protected); + let statuses = protected + .into_iter() + .map(|protected| { + if has_protected { + if protected { + StatusCode::FORBIDDEN + } else { + StatusCode::FAILED_DEPENDENCY + } + } else { + StatusCode::OK + } + }) + .collect(); + DavProppatchAtomicPlan { + apply: !has_protected, + statuses, + } +} + +/// Builds one PROPFIND multistatus item from a product-supplied property catalog. +/// +/// `available` contains every property exposed by `allprop`/`propname`. `resolve` supplies a value +/// for the requested QName and may intentionally return `None` for unavailable or hidden values. +pub fn build_propfind_item( + href: String, + request: &DavPropfindRequest, + available: &[DavRequestedProperty], + mut resolve: F, +) -> Result +where + F: FnMut(&DavRequestedProperty) -> Result, E>, +{ + let groups = match request { + DavPropfindRequest::AllProp { include } => { + let mut keys = available.iter().map(property_key).collect::>(); + let mut ok = resolve_available(available, &mut resolve)?; + let extra = include + .iter() + .filter(|property| keys.insert(property_key(property))) + .cloned() + .collect::>(); + let (mut included, missing) = resolve_requested(&extra, &mut resolve)?; + ok.append(&mut included); + propstat_groups(ok, missing) + } + DavPropfindRequest::PropName => vec![DavPropStat { + status: 200, + properties: available.iter().map(dav_property_name_element).collect(), + }], + DavPropfindRequest::Prop(requested) => { + let (ok, missing) = resolve_requested(requested, &mut resolve)?; + propstat_groups(ok, missing) + } + }; + Ok(DavMultiStatusItem::properties(href, groups)) +} + +/// Builds one PROPPATCH multistatus item by grouping property outcomes by status. +pub fn build_proppatch_item( + href: String, + outcomes: impl IntoIterator, +) -> DavMultiStatusItem { + let mut groups = BTreeMap::>::new(); + for (status, property) in outcomes { + groups.entry(status).or_default().push(property); + } + DavMultiStatusItem::properties( + href, + groups + .into_iter() + .map(|(status, properties)| DavPropStat { status, properties }) + .collect(), + ) +} + +/// Builds the 207 XML response for PROPFIND or PROPPATCH items. +pub fn property_multistatus_response( + items: Vec, +) -> Result { + xml_document_response(StatusCode::MULTI_STATUS, dav_multistatus_element(items)) +} + +/// Maps PROPFIND XML failures to their protocol response. +pub fn propfind_xml_error_response(error: DavXmlError) -> Result { + xml_request_error_response(error, "Invalid PROPFIND body") +} + +/// Maps PROPPATCH XML failures to their protocol response. +pub fn proppatch_xml_error_response(error: DavXmlError) -> Result { + xml_request_error_response(error, "Invalid PROPPATCH body") +} + +/// Builds the RFC 4918 finite-depth precondition response. +pub fn propfind_finite_depth_response() -> Result { + xml_document_response( + StatusCode::FORBIDDEN, + dav_error_element(&DavErrorCondition::PropfindFiniteDepth), + ) +} + +/// Returns a stable label for protocol metrics and tracing. +#[must_use] +pub const fn propfind_request_label(request: &DavPropfindRequest) -> &'static str { + match request { + DavPropfindRequest::AllProp { .. } => "allprop", + DavPropfindRequest::PropName => "propname", + DavPropfindRequest::Prop(_) => "prop", + } +} + +fn resolve_available( + available: &[DavRequestedProperty], + resolve: &mut F, +) -> Result, E> +where + F: FnMut(&DavRequestedProperty) -> Result, E>, +{ + let mut elements = Vec::with_capacity(available.len()); + for property in available { + if let Some(element) = resolve(property)? { + elements.push(element); + } + } + Ok(elements) +} + +fn resolve_requested( + requested: &[DavRequestedProperty], + resolve: &mut F, +) -> Result<(Vec, Vec), E> +where + F: FnMut(&DavRequestedProperty) -> Result, E>, +{ + let mut ok = Vec::new(); + let mut missing = Vec::new(); + for property in requested { + match resolve(property)? { + Some(element) => ok.push(element), + None => missing.push(dav_property_name_element(property)), + } + } + Ok((ok, missing)) +} + +fn propstat_groups(ok: Vec, missing: Vec) -> Vec { + let mut groups = Vec::with_capacity(2); + if !ok.is_empty() { + groups.push(DavPropStat { + status: 200, + properties: ok, + }); + } + if !missing.is_empty() { + groups.push(DavPropStat { + status: 404, + properties: missing, + }); + } + groups +} + +fn property_key(property: &DavRequestedProperty) -> PropertyKey { + (property.name.clone(), property.namespace.clone()) +} diff --git a/crates/aster_forge_webdav/src/protocol.rs b/crates/aster_forge_webdav/src/protocol.rs new file mode 100644 index 0000000..1f30e5a --- /dev/null +++ b/crates/aster_forge_webdav/src/protocol.rs @@ -0,0 +1,857 @@ +//! WebDAV header parsing and protocol precondition rules. + +use std::time::{Duration, SystemTime}; + +use http::header::{self, HeaderMap, HeaderValue}; +use http::uri::Authority; +use http::{StatusCode, Uri}; +use percent_encoding::percent_decode_str; + +use crate::{ + DavBackendError, DavFileSystem, DavIfResourceState, DavIfStateResolver, DavLockSystem, DavPath, + FsError, +}; +use aster_forge_utils::http_validators; +use async_trait::async_trait; + +/// WebDAV `Depth` header value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Depth { + /// The request target only. + Zero, + /// The target and its immediate children. + One, + /// The complete descendant tree. + Infinity, +} + +impl Depth { + /// Returns whether this depth traverses all descendants. + #[must_use] + pub fn is_infinity(self) -> bool { + matches!(self, Self::Infinity) + } +} + +/// A parsed `Destination` header restricted to the current origin and mount. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Destination { + /// Canonical path relative to the WebDAV mount. + pub path: DavPath, + /// Decoded relative path retained for product adapters. + pub relative: String, +} + +/// A parsed WebDAV `If` header. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IfHeader { + /// Resource-tagged or untagged condition groups. + pub groups: Vec, +} + +/// Conditions associated with one tagged resource or the request target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IfResourceGroup { + /// Tagged resource URI, or `None` for the request target. + pub tagged_path: Option, + /// OR-connected state lists for this resource. + pub lists: Vec, +} + +/// AND-connected conditions inside one parenthesized state list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IfStateList { + /// State token and entity-tag conditions. + pub conditions: Vec, +} + +/// One WebDAV `If` condition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IfStateCondition { + /// A lock state token condition. + Token { value: String, negated: bool }, + /// An entity-tag condition. + Etag { value: String, negated: bool }, +} + +/// Failure while resolving and evaluating a WebDAV `If` request precondition. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DavIfEvaluationError { + #[error(transparent)] + Protocol(#[from] DavProtocolError), + #[error(transparent)] + Backend(#[from] DavBackendError), +} + +/// Outcome of HTTP entity-tag and modification-date precondition evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavPrecondition { + /// Continue processing the request. + Proceed, + /// Return `304 Not Modified` for a safe method. + NotModified, +} + +/// Stable protocol error classification for transport adapters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavProtocolErrorKind { + BadRequest, + PreconditionFailed, +} + +/// A product-neutral WebDAV protocol error. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{message}")] +pub struct DavProtocolError { + kind: DavProtocolErrorKind, + status: StatusCode, + message: &'static str, +} + +impl DavProtocolError { + /// Returns the stable error category. + #[must_use] + pub fn kind(&self) -> DavProtocolErrorKind { + self.kind + } + + /// Returns the HTTP status required by the protocol boundary. + #[must_use] + pub fn status(&self) -> StatusCode { + self.status + } + + /// Returns a protocol-level response message. + #[must_use] + pub fn message(&self) -> &'static str { + self.message + } + + pub(crate) fn bad_request(message: &'static str) -> Self { + Self { + kind: DavProtocolErrorKind::BadRequest, + status: StatusCode::BAD_REQUEST, + message, + } + } + + fn precondition_failed() -> Self { + Self { + kind: DavProtocolErrorKind::PreconditionFailed, + status: StatusCode::PRECONDITION_FAILED, + message: "Precondition failed", + } + } +} + +/// Parses the `Depth` semantics used by `PROPFIND`. +pub fn parse_propfind_depth(headers: &HeaderMap) -> Result { + match parse_depth_header(headers)? { + Some(Depth::Zero) => Ok(Depth::Zero), + Some(Depth::One) => Ok(Depth::One), + Some(Depth::Infinity) | None => Ok(Depth::Infinity), + } +} + +/// Parses the `Depth` semantics used by `COPY`. +pub fn parse_copy_depth(headers: &HeaderMap) -> Result { + match parse_depth_header(headers)? { + Some(Depth::Zero) => Ok(Depth::Zero), + Some(Depth::Infinity) | None => Ok(Depth::Infinity), + Some(Depth::One) => Err(DavProtocolError::bad_request("Invalid Depth header")), + } +} + +/// Parses the `Depth` semantics used by `MOVE`. +pub fn parse_move_depth(headers: &HeaderMap) -> Result { + Ok(parse_depth_header(headers)?.unwrap_or(Depth::Infinity)) +} + +/// Parses the `Depth` semantics used by `DELETE`. +pub fn parse_delete_depth(headers: &HeaderMap) -> Result { + Ok(parse_depth_header(headers)?.unwrap_or(Depth::Infinity)) +} + +/// Parses the `Depth` semantics used by `LOCK`. +pub fn parse_lock_depth(headers: &HeaderMap) -> Result { + match parse_depth_header(headers)? { + None | Some(Depth::Infinity) => Ok(Depth::Infinity), + Some(Depth::Zero) => Ok(Depth::Zero), + Some(Depth::One) => Err(DavProtocolError::bad_request("Invalid Depth header")), + } +} + +fn parse_depth_header(headers: &HeaderMap) -> Result, DavProtocolError> { + let Some(value) = headers.get("Depth") else { + return Ok(None); + }; + let value = value + .to_str() + .map_err(|_| DavProtocolError::bad_request("Invalid Depth header"))?; + + match value { + value if value.eq_ignore_ascii_case("0") => Ok(Some(Depth::Zero)), + value if value.eq_ignore_ascii_case("1") => Ok(Some(Depth::One)), + value if value.eq_ignore_ascii_case("infinity") => Ok(Some(Depth::Infinity)), + _ => Err(DavProtocolError::bad_request("Invalid Depth header")), + } +} + +/// Parses `Overwrite`, defaulting to `true` when the header is absent. +pub fn parse_overwrite(headers: &HeaderMap) -> Result { + let Some(value) = headers.get("Overwrite") else { + return Ok(true); + }; + let value = value + .to_str() + .map_err(|_| DavProtocolError::bad_request("Invalid Overwrite header"))? + .trim(); + if value.eq_ignore_ascii_case("T") { + Ok(true) + } else if value.eq_ignore_ascii_case("F") { + Ok(false) + } else { + Err(DavProtocolError::bad_request("Invalid Overwrite header")) + } +} + +/// Parses and constrains `Destination` to the current origin and WebDAV mount. +pub fn destination_relative_path( + headers: &HeaderMap, + prefix: &str, + request_scheme: &str, + request_host: &str, +) -> Result { + let raw = headers + .get("Destination") + .ok_or_else(|| DavProtocolError::bad_request("Missing Destination header"))? + .to_str() + .map_err(|_| DavProtocolError::bad_request("Invalid Destination header"))? + .trim(); + let uri: Uri = raw + .parse() + .map_err(|_| DavProtocolError::bad_request("Invalid Destination header"))?; + match (uri.scheme_str(), uri.authority()) { + (Some(scheme), Some(authority)) => { + if !origin_authority_matches(scheme, authority, request_scheme, request_host) { + return Err(DavProtocolError::bad_request( + "Destination must stay on this WebDAV server", + )); + } + } + (None, None) => { + if !raw.starts_with('/') { + return Err(DavProtocolError::bad_request("Invalid Destination header")); + } + } + _ => return Err(DavProtocolError::bad_request("Invalid Destination header")), + } + + let path = uri.path(); + let relative = strip_mount_prefix(path, prefix).ok_or_else(|| { + DavProtocolError::bad_request("Destination must stay under WebDAV prefix") + })?; + let path = DavPath::new(relative) + .map_err(|_| DavProtocolError::bad_request("Invalid Destination header"))?; + let relative = path.as_str().to_string(); + Ok(Destination { path, relative }) +} + +/// Parses a WebDAV `If` header. +pub fn parse_if_header(headers: &HeaderMap) -> Result, DavProtocolError> { + let Some(value) = headers.get("If") else { + return Ok(None); + }; + let raw = value + .to_str() + .map_err(|_| DavProtocolError::bad_request("Invalid If header"))?; + IfHeaderParser::new(raw).parse().map(Some) +} + +/// Resolves referenced resources and enforces the complete WebDAV `If` state-list precondition. +/// +/// Conditions inside one list are AND-connected. Lists for one resource and tagged resource +/// productions across the complete header are OR-connected. +pub async fn enforce_if_header( + if_header: Option<&IfHeader>, + resolver: &dyn DavIfStateResolver, + request_path: &DavPath, + prefix: &str, + request_scheme: &str, + request_host: &str, +) -> Result<(), DavIfEvaluationError> { + let Some(if_header) = if_header else { + return Ok(()); + }; + + for group in &if_header.groups { + let path = match group.tagged_path.as_deref() { + Some(tagged_path) => { + tagged_dav_path(prefix, tagged_path, request_scheme, request_host)? + } + None => Some(request_path.clone()), + }; + let state = match path.as_ref() { + Some(path) => resolver.resolve_if_state(path).await?, + None => DavIfResourceState::default(), + }; + if group + .lists + .iter() + .any(|list| evaluate_if_state_list(list, &state)) + { + return Ok(()); + } + } + Err(DavProtocolError::precondition_failed().into()) +} + +/// Resolves `If` state from the canonical filesystem and lock backend ports. +/// +/// Products normally use this entrypoint instead of implementing a transport-local resolver. +/// A missing resource contributes no ETag, while all other filesystem failures preserve the +/// backend error boundary. +pub async fn enforce_if_header_with_backends( + if_header: Option<&IfHeader>, + filesystem: &dyn DavFileSystem, + lock_system: &dyn DavLockSystem, + request_path: &DavPath, + prefix: &str, + request_scheme: &str, + request_host: &str, +) -> Result<(), DavIfEvaluationError> { + let resolver = BackendIfStateResolver { + filesystem, + lock_system, + }; + enforce_if_header( + if_header, + &resolver, + request_path, + prefix, + request_scheme, + request_host, + ) + .await +} + +struct BackendIfStateResolver<'a> { + filesystem: &'a dyn DavFileSystem, + lock_system: &'a dyn DavLockSystem, +} + +#[async_trait] +impl DavIfStateResolver for BackendIfStateResolver<'_> { + async fn resolve_if_state( + &self, + path: &DavPath, + ) -> Result { + let etag = match self.filesystem.metadata(path).await { + Ok(metadata) => metadata.etag(), + Err(FsError::NotFound) => None, + Err(error) => return Err(error.into()), + }; + let lock_tokens = self + .lock_system + .discover(path) + .await + .into_iter() + .map(|lock| lock.token) + .collect(); + Ok(DavIfResourceState { etag, lock_tokens }) + } +} + +fn evaluate_if_state_list(list: &IfStateList, state: &DavIfResourceState) -> bool { + list.conditions.iter().all(|condition| match condition { + IfStateCondition::Token { value, negated } => { + state.lock_tokens.iter().any(|token| token == value) ^ *negated + } + IfStateCondition::Etag { value, negated } => { + state.etag.as_deref().is_some_and(|etag| { + http_validators::if_none_match_header_matches(value, true, Some(etag)) + .unwrap_or(false) + }) ^ *negated + } + }) +} + +fn tagged_dav_path( + prefix: &str, + tagged_path: &str, + request_scheme: &str, + request_host: &str, +) -> Result, DavProtocolError> { + let uri: Uri = tagged_path + .parse() + .map_err(|_| DavProtocolError::bad_request("Invalid If header"))?; + let path = match (uri.scheme_str(), uri.authority()) { + (Some(scheme), Some(authority)) => { + if !origin_authority_matches(scheme, authority, request_scheme, request_host) { + return Ok(None); + } + uri.path() + } + (None, None) => uri.path(), + _ => return Err(DavProtocolError::bad_request("Invalid If header")), + }; + if !path.starts_with('/') { + return Err(DavProtocolError::bad_request("Invalid If header")); + } + let Some(relative) = strip_mount_prefix(path, prefix) else { + return Ok(None); + }; + DavPath::new(relative) + .map(Some) + .map_err(|_| DavProtocolError::bad_request("Invalid If header")) +} + +/// Extracts submitted lock tokens that apply to one request path. +pub fn submitted_lock_tokens_for_path( + headers: &HeaderMap, + request_path: &str, + request_scheme: &str, + request_host: &str, +) -> Vec { + let Some(if_header) = parse_if_header(headers).ok().flatten() else { + return Vec::new(); + }; + submitted_lock_tokens(&if_header, request_path, request_scheme, request_host) +} + +/// Extracts submitted lock tokens from an already parsed `If` header. +#[must_use] +pub fn submitted_lock_tokens( + if_header: &IfHeader, + request_path: &str, + request_scheme: &str, + request_host: &str, +) -> Vec { + let mut tokens = Vec::new(); + for group in &if_header.groups { + match group.tagged_path.as_deref() { + None => {} + Some(tagged_path) + if if_tag_matches_path(tagged_path, request_path, request_scheme, request_host) => { + } + Some(_) => continue, + } + for list in &group.lists { + for condition in &list.conditions { + if let IfStateCondition::Token { value, .. } = condition { + tokens.push(value.clone()); + } + } + } + } + tokens.sort(); + tokens.dedup(); + tokens +} + +/// Parses a bounded LOCK timeout using a product-supplied maximum duration. +pub fn parse_lock_timeout( + headers: &HeaderMap, + maximum: Duration, +) -> Result { + let Some(value) = headers.get("Timeout") else { + return Ok(maximum); + }; + let raw = value + .to_str() + .map_err(|_| DavProtocolError::bad_request("Invalid Timeout header"))?; + for candidate in raw + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if candidate.eq_ignore_ascii_case("Infinite") { + return Ok(maximum); + } + if let Some(seconds) = candidate + .strip_prefix("Second-") + .and_then(|seconds| seconds.parse::().ok()) + { + return Ok(Duration::from_secs(seconds).min(maximum)); + } + } + Err(DavProtocolError::bad_request("Invalid Timeout header")) +} + +/// Parses the required angle-bracketed `Lock-Token` request header. +pub fn parse_lock_token_header(headers: &HeaderMap) -> Result { + let raw = headers + .get("Lock-Token") + .ok_or_else(|| DavProtocolError::bad_request("Missing Lock-Token header"))? + .to_str() + .map_err(|_| DavProtocolError::bad_request("Invalid Lock-Token header"))? + .trim(); + let token = raw + .strip_prefix('<') + .and_then(|value| value.strip_suffix('>')) + .filter(|token| !token.is_empty() && !token.contains(['<', '>'])) + .ok_or_else(|| DavProtocolError::bad_request("Invalid Lock-Token header"))?; + Ok(token.to_owned()) +} + +/// Evaluates `If-Match` and `If-None-Match` for a resource operation. +pub fn evaluate_http_etag_preconditions( + headers: &HeaderMap, + resource_exists: bool, + current_etag: Option<&str>, + safe_method: bool, +) -> Result { + if let Some(value) = headers.get(header::IF_MATCH) { + let raw = value + .to_str() + .map_err(|_| DavProtocolError::bad_request("Invalid If-Match header"))?; + if !http_validators::if_match_header_matches(raw, resource_exists, current_etag) + .map_err(|_| DavProtocolError::bad_request("Invalid If-Match header"))? + { + return Err(DavProtocolError::precondition_failed()); + } + } + + if let Some(value) = headers.get(header::IF_NONE_MATCH) { + let raw = value + .to_str() + .map_err(|_| DavProtocolError::bad_request("Invalid If-None-Match header"))?; + if http_validators::if_none_match_header_matches(raw, resource_exists, current_etag) + .map_err(|_| DavProtocolError::bad_request("Invalid If-None-Match header"))? + { + return if safe_method { + Ok(DavPrecondition::NotModified) + } else { + Err(DavProtocolError::precondition_failed()) + }; + } + } + + Ok(DavPrecondition::Proceed) +} + +/// Evaluates RFC 7232 download preconditions in their required precedence order. +pub fn evaluate_http_download_preconditions( + headers: &HeaderMap, + current_etag: Option<&str>, + last_modified: Option, +) -> Result { + let has_if_match = headers.contains_key(header::IF_MATCH); + if let Some(value) = headers.get(header::IF_MATCH) { + let raw = value + .to_str() + .map_err(|_| DavProtocolError::bad_request("Invalid If-Match header"))?; + if !http_validators::if_match_header_matches(raw, true, current_etag) + .map_err(|_| DavProtocolError::bad_request("Invalid If-Match header"))? + { + return Err(DavProtocolError::precondition_failed()); + } + } + + if !has_if_match + && let (Some(value), Some(last_modified)) = + (headers.get(header::IF_UNMODIFIED_SINCE), last_modified) + { + let since = parse_http_date_header(value, "Invalid If-Unmodified-Since header")?; + if http_validators::http_date_epoch_seconds(last_modified) + > http_validators::http_date_epoch_seconds(since) + { + return Err(DavProtocolError::precondition_failed()); + } + } + + let has_if_none_match = headers.contains_key(header::IF_NONE_MATCH); + if let Some(value) = headers.get(header::IF_NONE_MATCH) { + let raw = value + .to_str() + .map_err(|_| DavProtocolError::bad_request("Invalid If-None-Match header"))?; + if http_validators::if_none_match_header_matches(raw, true, current_etag) + .map_err(|_| DavProtocolError::bad_request("Invalid If-None-Match header"))? + { + return Ok(DavPrecondition::NotModified); + } + } + + if !has_if_none_match + && let (Some(value), Some(last_modified)) = + (headers.get(header::IF_MODIFIED_SINCE), last_modified) + { + let since = parse_http_date_header(value, "Invalid If-Modified-Since header")?; + if http_validators::http_date_epoch_seconds(last_modified) + <= http_validators::http_date_epoch_seconds(since) + { + return Ok(DavPrecondition::NotModified); + } + } + + Ok(DavPrecondition::Proceed) +} + +fn parse_http_date_header( + value: &HeaderValue, + invalid_message: &'static str, +) -> Result { + let raw = value + .to_str() + .map_err(|_| DavProtocolError::bad_request(invalid_message))?; + http_validators::parse_http_date(raw) + .map_err(|_| DavProtocolError::bad_request(invalid_message)) +} + +fn origin_authority_matches( + uri_scheme: &str, + uri_authority: &Authority, + request_scheme: &str, + request_host: &str, +) -> bool { + if !uri_scheme.eq_ignore_ascii_case(request_scheme) { + return false; + } + let Ok(request_authority) = request_host.parse::() else { + return false; + }; + if uri_authority.as_str().contains('@') || request_authority.as_str().contains('@') { + return false; + } + uri_authority + .host() + .eq_ignore_ascii_case(request_authority.host()) + && effective_port(uri_scheme, uri_authority) + == effective_port(request_scheme, &request_authority) +} + +fn effective_port(scheme: &str, authority: &Authority) -> Option { + authority.port_u16().or_else(|| { + if scheme.eq_ignore_ascii_case("http") { + Some(80) + } else if scheme.eq_ignore_ascii_case("https") { + Some(443) + } else { + None + } + }) +} + +pub(crate) fn strip_mount_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> { + path.strip_prefix(prefix).filter(|_| { + prefix == "/" + || path == prefix + || path + .as_bytes() + .get(prefix.len()) + .is_some_and(|byte| *byte == b'/') + }) +} + +fn normalize_lock_token(value: &str) -> String { + value + .trim() + .trim_matches(|character| character == '<' || character == '>') + .to_string() +} + +struct IfHeaderParser<'a> { + input: &'a str, + position: usize, +} + +impl<'a> IfHeaderParser<'a> { + fn new(input: &'a str) -> Self { + Self { input, position: 0 } + } + + fn parse(&mut self) -> Result { + self.skip_linear_whitespace(); + if self.is_eof() { + return Err(DavProtocolError::bad_request("Invalid If header")); + } + + let tagged = self.peek_char() == Some('<'); + let mut groups = Vec::new(); + if tagged { + while !self.is_eof() { + let tagged_path = self.parse_angle_value()?; + let mut lists = Vec::new(); + loop { + self.skip_linear_whitespace(); + if self.peek_char() != Some('(') { + break; + } + lists.push(self.parse_state_list()?); + } + if lists.is_empty() { + return Err(DavProtocolError::bad_request("Invalid If header")); + } + groups.push(IfResourceGroup { + tagged_path: Some(tagged_path), + lists, + }); + self.skip_linear_whitespace(); + if self.is_eof() { + break; + } + if self.peek_char() != Some('<') { + return Err(DavProtocolError::bad_request("Invalid If header")); + } + } + } else { + let mut lists = Vec::new(); + while !self.is_eof() { + lists.push(self.parse_state_list()?); + self.skip_linear_whitespace(); + if self.peek_char() == Some('<') { + return Err(DavProtocolError::bad_request("Invalid If header")); + } + } + groups.push(IfResourceGroup { + tagged_path: None, + lists, + }); + } + Ok(IfHeader { groups }) + } + + fn parse_state_list(&mut self) -> Result { + self.expect_char('(')?; + let mut conditions = Vec::new(); + loop { + self.skip_linear_whitespace(); + if self.peek_char() == Some(')') { + self.position += 1; + break; + } + if self.is_eof() { + return Err(DavProtocolError::bad_request("Invalid If header")); + } + + let negated = self.consume_not(); + self.skip_linear_whitespace(); + let condition = match self.peek_char() { + Some('<') => IfStateCondition::Token { + value: normalize_lock_token(&self.parse_angle_value()?), + negated, + }, + Some('[') => IfStateCondition::Etag { + value: self.parse_bracket_value()?, + negated, + }, + _ => return Err(DavProtocolError::bad_request("Invalid If header")), + }; + conditions.push(condition); + } + + if conditions.is_empty() { + return Err(DavProtocolError::bad_request("Invalid If header")); + } + Ok(IfStateList { conditions }) + } + + fn parse_angle_value(&mut self) -> Result { + self.parse_delimited('<', '>') + } + + fn parse_bracket_value(&mut self) -> Result { + self.parse_delimited('[', ']') + } + + fn parse_delimited( + &mut self, + opening: char, + closing: char, + ) -> Result { + self.expect_char(opening)?; + let start = self.position; + while let Some(character) = self.peek_char() { + if character == closing { + let value = self.input[start..self.position].trim(); + self.position += closing.len_utf8(); + if value.is_empty() { + return Err(DavProtocolError::bad_request("Invalid If header")); + } + return Ok(value.to_string()); + } + self.position += character.len_utf8(); + } + Err(DavProtocolError::bad_request("Invalid If header")) + } + + fn consume_not(&mut self) -> bool { + let rest = &self.input[self.position..]; + let Some(candidate) = rest.get(..3) else { + return false; + }; + if !candidate.eq_ignore_ascii_case("not") { + return false; + } + let after_not = &rest[3..]; + if after_not.chars().next().is_some_and(|character| { + !character.is_ascii_whitespace() && character != '<' && character != '[' + }) { + return false; + } + self.position += 3; + true + } + + fn expect_char(&mut self, expected: char) -> Result<(), DavProtocolError> { + if self.peek_char() == Some(expected) { + self.position += expected.len_utf8(); + Ok(()) + } else { + Err(DavProtocolError::bad_request("Invalid If header")) + } + } + + fn skip_linear_whitespace(&mut self) { + while self + .peek_char() + .is_some_and(|character| matches!(character, ' ' | '\t' | '\r' | '\n')) + { + self.position += 1; + } + } + + fn peek_char(&self) -> Option { + self.input[self.position..].chars().next() + } + + fn is_eof(&self) -> bool { + self.position >= self.input.len() + } +} + +fn if_tag_matches_path( + tagged_path: &str, + request_path: &str, + request_scheme: &str, + request_host: &str, +) -> bool { + if path_equivalent(tagged_path, request_path) { + return true; + } + let Ok(uri) = tagged_path.parse::() else { + return false; + }; + match (uri.scheme_str(), uri.authority()) { + (Some(scheme), Some(authority)) => { + origin_authority_matches(scheme, authority, request_scheme, request_host) + && path_equivalent(uri.path(), request_path) + } + (None, None) => path_equivalent(uri.path(), request_path), + _ => false, + } +} + +fn path_equivalent(left: &str, right: &str) -> bool { + if left == right { + return true; + } + let left_decoded = percent_decode_str(left).decode_utf8().ok(); + let right_decoded = percent_decode_str(right).decode_utf8().ok(); + match (left_decoded.as_deref(), right_decoded.as_deref()) { + (Some(left), Some(right)) => left == right, + (Some(left), None) => left == right, + (None, Some(right)) => left == right, + (None, None) => false, + } +} diff --git a/crates/aster_forge_webdav/src/put.rs b/crates/aster_forge_webdav/src/put.rs new file mode 100644 index 0000000..2a6c38c --- /dev/null +++ b/crates/aster_forge_webdav/src/put.rs @@ -0,0 +1,109 @@ +//! PUT request planning and success response composition. + +use http::header::{CACHE_CONTROL, CONTENT_LOCATION, IF_MATCH, IF_NONE_MATCH}; +use http::{HeaderMap, HeaderValue, StatusCode}; + +use crate::{ + DavPath, DavProtocolError, DavResponse, evaluate_http_etag_preconditions, href_for_dav_path, +}; + +/// Resolved state of the PUT request target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavPutResourceState<'a> { + Missing, + File { etag: Option<&'a str> }, + Collection, +} + +/// Product-side open/write settings selected from HTTP preconditions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DavPutPlan { + pub resource_existed: bool, + pub create: bool, + pub create_new: bool, + pub content_length_hint: Option, +} + +/// Failure while planning a PUT request. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DavPutPlanError { + #[error(transparent)] + Protocol(#[from] DavProtocolError), + #[error("PUT cannot replace a collection")] + CollectionTarget, +} + +/// Failure while composing the successful PUT response. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("invalid PUT Content-Location response header")] +pub struct DavPutResponseError; + +/// Evaluates PUT preconditions and selects product-side create/open settings. +pub fn plan_put_request( + headers: &HeaderMap, + state: DavPutResourceState<'_>, +) -> Result { + let (resource_existed, etag) = match state { + DavPutResourceState::Missing => (false, None), + DavPutResourceState::File { etag } => (true, etag), + DavPutResourceState::Collection => return Err(DavPutPlanError::CollectionTarget), + }; + evaluate_http_etag_preconditions(headers, resource_existed, etag, false)?; + Ok(DavPutPlan { + resource_existed, + create: !header_equals(headers, IF_MATCH, "*"), + create_new: header_equals(headers, IF_NONE_MATCH, "*"), + content_length_hint: content_length_hint(headers), + }) +} + +/// Maps a PUT planning failure to its protocol response. +#[must_use] +pub fn put_plan_error_response(error: &DavPutPlanError) -> DavResponse { + match error { + DavPutPlanError::Protocol(error) => crate::protocol_error_response(error), + DavPutPlanError::CollectionTarget => { + let mut response = DavResponse::empty(StatusCode::METHOD_NOT_ALLOWED); + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response + } + } +} + +/// Builds the 201/204 response selected by the pre-write resource state. +pub fn put_success_response( + plan: &DavPutPlan, + prefix: &str, + path: &DavPath, +) -> Result { + if plan.resource_existed { + return Ok(DavResponse::empty(StatusCode::NO_CONTENT)); + } + let mut response = DavResponse::empty(StatusCode::CREATED); + let location = + HeaderValue::from_str(&href_for_dav_path(prefix, path)).map_err(|_| DavPutResponseError)?; + response.headers.insert(CONTENT_LOCATION, location); + Ok(response) +} + +fn content_length_hint(headers: &HeaderMap) -> Option { + headers + .get("X-Expected-Entity-Length") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .or_else(|| { + headers + .get(http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + }) +} + +fn header_equals(headers: &HeaderMap, name: http::header::HeaderName, expected: &str) -> bool { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.trim() == expected) +} diff --git a/crates/aster_forge_webdav/src/request.rs b/crates/aster_forge_webdav/src/request.rs new file mode 100644 index 0000000..35c9d27 --- /dev/null +++ b/crates/aster_forge_webdav/src/request.rs @@ -0,0 +1,176 @@ +//! Transport-neutral WebDAV request head parsing. + +use http::{HeaderMap, Method, Uri}; + +use crate::DavPath; +use crate::event::DavOperation; +use crate::protocol::{ + DavProtocolError, Depth, Destination, IfHeader, destination_relative_path, parse_copy_depth, + parse_delete_depth, parse_if_header, parse_lock_depth, parse_move_depth, parse_overwrite, + parse_propfind_depth, strip_mount_prefix, +}; + +/// WebDAV method recognized by the protocol layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavMethod { + Options, + Propfind, + Proppatch, + Get, + Head, + Put, + Mkcol, + Delete, + Copy, + Move, + Lock, + Unlock, + Report, + VersionControl, +} + +/// How the transport adapter must handle a request body before product code runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavBodyPolicy { + /// Reject the first non-empty body chunk. + Empty, + /// Collect the body up to the product-supplied XML limit. + BoundedXml, + /// Leave the body as a stream for the product storage adapter. + Stream, + /// Preserve the existing method behavior without consuming the body. + Unused, +} + +impl DavMethod { + /// Parses a supported HTTP/WebDAV method. + #[must_use] + pub fn from_method(method: &Method) -> Option { + Self::from_name(method.as_str()) + } + + /// Parses a supported HTTP/WebDAV method name across transport implementations. + #[must_use] + pub fn from_name(method: &str) -> Option { + match method { + "OPTIONS" => Some(Self::Options), + "PROPFIND" => Some(Self::Propfind), + "PROPPATCH" => Some(Self::Proppatch), + "GET" => Some(Self::Get), + "HEAD" => Some(Self::Head), + "PUT" => Some(Self::Put), + "MKCOL" => Some(Self::Mkcol), + "DELETE" => Some(Self::Delete), + "COPY" => Some(Self::Copy), + "MOVE" => Some(Self::Move), + "LOCK" => Some(Self::Lock), + "UNLOCK" => Some(Self::Unlock), + "REPORT" => Some(Self::Report), + "VERSION-CONTROL" => Some(Self::VersionControl), + _ => None, + } + } + + /// Returns the corresponding observable operation. + #[must_use] + pub const fn operation(self) -> DavOperation { + match self { + Self::Options => DavOperation::Options, + Self::Propfind => DavOperation::Propfind, + Self::Proppatch => DavOperation::Proppatch, + Self::Get => DavOperation::Get, + Self::Head => DavOperation::Head, + Self::Put => DavOperation::Put, + Self::Mkcol => DavOperation::Mkcol, + Self::Delete => DavOperation::Delete, + Self::Copy => DavOperation::Copy, + Self::Move => DavOperation::Move, + Self::Lock => DavOperation::Lock, + Self::Unlock => DavOperation::Unlock, + Self::Report => DavOperation::Report, + Self::VersionControl => DavOperation::VersionControl, + } + } + + /// Returns the body handling contract for this method. + #[must_use] + pub const fn body_policy(self) -> DavBodyPolicy { + match self { + Self::Options | Self::Mkcol | Self::Delete | Self::Copy | Self::Move | Self::Unlock => { + DavBodyPolicy::Empty + } + Self::Propfind | Self::Proppatch | Self::Lock | Self::Report => { + DavBodyPolicy::BoundedXml + } + Self::Put => DavBodyPolicy::Stream, + Self::Get | Self::Head | Self::VersionControl => DavBodyPolicy::Unused, + } + } +} + +/// Request origin needed for same-origin tagged URI and destination validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavRequestOrigin { + pub scheme: String, + pub host: String, +} + +/// Parsed, body-independent WebDAV request data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavRequestHead { + pub method: DavMethod, + pub target: DavPath, + pub origin: DavRequestOrigin, + pub depth: Option, + pub overwrite: Option, + pub destination: Option, + pub if_header: Option, +} + +impl DavRequestHead { + /// Parses protocol headers and the mount-relative target before product code is called. + pub fn parse( + method: DavMethod, + uri: &Uri, + headers: &HeaderMap, + mount_path: &str, + origin: &DavRequestOrigin, + ) -> Result { + let relative = strip_mount_prefix(uri.path(), mount_path).ok_or_else(|| { + DavProtocolError::bad_request("Request target must stay under WebDAV prefix") + })?; + let target = DavPath::new(relative) + .map_err(|_| DavProtocolError::bad_request("Invalid request path"))?; + + let depth = match method { + DavMethod::Propfind => Some(parse_propfind_depth(headers)?), + DavMethod::Copy => Some(parse_copy_depth(headers)?), + DavMethod::Move => Some(parse_move_depth(headers)?), + DavMethod::Delete => Some(parse_delete_depth(headers)?), + DavMethod::Lock => Some(parse_lock_depth(headers)?), + _ => None, + }; + let (overwrite, destination) = match method { + DavMethod::Copy | DavMethod::Move => ( + Some(parse_overwrite(headers)?), + Some(destination_relative_path( + headers, + mount_path, + &origin.scheme, + &origin.host, + )?), + ), + _ => (None, None), + }; + + Ok(Self { + method, + target, + origin: origin.clone(), + depth, + overwrite, + destination, + if_header: parse_if_header(headers)?, + }) + } +} diff --git a/crates/aster_forge_webdav/src/resource.rs b/crates/aster_forge_webdav/src/resource.rs new file mode 100644 index 0000000..b5f0d7a --- /dev/null +++ b/crates/aster_forge_webdav/src/resource.rs @@ -0,0 +1,297 @@ +//! Resource mutation path rules and response composition. + +use http::header::{CACHE_CONTROL, CONTENT_LOCATION, CONTENT_TYPE}; +use http::{HeaderValue, StatusCode}; + +use crate::response::no_store_empty_response; +use crate::{ + DavErrorCondition, DavFileSystem, DavMultiStatusItem, DavPath, DavResourceKind, DavResponse, + DavXmlError, Depth, FsError, backend_error_response, dav_multistatus_element, + href_for_dav_path, parent_relative_path, +}; + +/// COPY or MOVE operation selected by the request method. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavCopyMoveMethod { + Copy, + Move, +} + +/// Resource-shape decisions needed by the Drive mutation adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DavCopyMovePlan { + pub recursive_collection: bool, + pub destination_deep: bool, +} + +/// Protocol failure selected after source/destination metadata is known. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DavMutationPlanError { + #[error("invalid mutation Depth")] + BadRequest, + #[error("resource mutation is not supported for this target")] + MethodNotAllowed, + #[error("resource mutation conflicts with the current hierarchy")] + Conflict, + #[error("forbidden mutation path relation")] + Forbidden, + #[error("destination exists while Overwrite is disabled")] + PreconditionFailed, +} + +/// Rejects collection creation for the DAV root resource. +pub fn validate_collection_create_target(path: &str) -> Result<(), DavMutationPlanError> { + if resource_identity_path(path) == "/" { + Err(DavMutationPlanError::MethodNotAllowed) + } else { + Ok(()) + } +} + +/// Requires the canonical parent of a mutation target to exist as a collection. +/// +/// The DAV mount root is an implicit collection and does not require a backend lookup. A target +/// without a parent identifies the mount root itself and is rejected for mutation. +pub async fn enforce_parent_collection( + filesystem: &dyn DavFileSystem, + target: &DavPath, +) -> Result<(), DavResponse> { + let Some(parent) = parent_relative_path(target.as_str()) else { + return Err(mutation_plan_error_response( + DavMutationPlanError::MethodNotAllowed, + )); + }; + if parent == "/" { + return Ok(()); + } + let parent = match DavPath::new(&parent) { + Ok(parent) => parent, + Err(_) => { + return Err(mutation_plan_error_response( + DavMutationPlanError::BadRequest, + )); + } + }; + match filesystem.metadata(&parent).await { + Ok(metadata) if metadata.is_dir() => Ok(()), + Ok(_) | Err(FsError::NotFound) => { + Err(mutation_plan_error_response(DavMutationPlanError::Conflict)) + } + Err(error) => Err(backend_error_response(&error.into())), + } +} + +/// Failure while composing a mutation success response header. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("invalid mutation Content-Location response header")] +pub struct DavMutationResponseError; + +/// One resource-level failure from a recursive COPY, MOVE, or DELETE operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavMutationFailure { + path: DavPath, + status: u16, + lock_path: Option, +} + +impl DavMutationFailure { + /// Creates a recursive mutation failure caused by an unsubmitted lock token. + #[must_use] + pub fn locked(path: DavPath, lock_path: DavPath) -> Self { + Self { + path, + status: StatusCode::LOCKED.as_u16(), + lock_path: Some(lock_path), + } + } + + /// Creates a recursive mutation failure with an explicit protocol status. + #[must_use] + pub fn status(path: DavPath, status: u16) -> Self { + Self { + path, + status, + lock_path: None, + } + } +} + +/// Enforces collection DELETE Depth after product metadata resolution. +pub fn validate_delete_target( + kind: DavResourceKind, + depth: Depth, +) -> Result<(), DavMutationPlanError> { + if kind == DavResourceKind::Collection && !depth.is_infinity() { + Err(DavMutationPlanError::BadRequest) + } else { + Ok(()) + } +} + +/// Plans COPY/MOVE resource-shape behavior after product metadata resolution. +pub fn plan_copy_move_request( + method: DavCopyMoveMethod, + depth: Depth, + source_kind: DavResourceKind, + destination_kind: Option, + source_path: &str, + destination_path: &str, + overwrite: bool, +) -> Result { + if same_resource_path(source_path, destination_path) { + return Err(DavMutationPlanError::Forbidden); + } + if source_kind == DavResourceKind::Collection { + match method { + DavCopyMoveMethod::Move if !depth.is_infinity() => { + return Err(DavMutationPlanError::BadRequest); + } + DavCopyMoveMethod::Copy if depth == Depth::One => { + return Err(DavMutationPlanError::BadRequest); + } + DavCopyMoveMethod::Copy | DavCopyMoveMethod::Move => {} + } + } + let recursive_collection = source_kind == DavResourceKind::Collection + && (method == DavCopyMoveMethod::Move || depth != Depth::Zero); + if recursive_collection && is_descendant_path(source_path, destination_path) { + return Err(DavMutationPlanError::Forbidden); + } + if !overwrite && destination_kind.is_some() { + return Err(DavMutationPlanError::PreconditionFailed); + } + let destination_deep = destination_kind == Some(DavResourceKind::Collection) + || source_kind == DavResourceKind::Collection + && (method == DavCopyMoveMethod::Move || depth != Depth::Zero); + Ok(DavCopyMovePlan { + recursive_collection, + destination_deep, + }) +} + +/// Builds an empty response for resource-shape validation failure. +#[must_use] +pub fn mutation_plan_error_response(error: DavMutationPlanError) -> DavResponse { + let status = match error { + DavMutationPlanError::BadRequest => StatusCode::BAD_REQUEST, + DavMutationPlanError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED, + DavMutationPlanError::Conflict => StatusCode::CONFLICT, + DavMutationPlanError::Forbidden => StatusCode::FORBIDDEN, + DavMutationPlanError::PreconditionFailed => StatusCode::PRECONDITION_FAILED, + }; + no_store_empty_response(status) +} + +/// Builds the successful MKCOL response. +pub fn collection_created_response( + prefix: &str, + path: &DavPath, +) -> Result { + let mut response = DavResponse::empty(StatusCode::CREATED); + let location = HeaderValue::from_str(&href_for_dav_path(prefix, path)) + .map_err(|_| DavMutationResponseError)?; + response.headers.insert(CONTENT_LOCATION, location); + Ok(response) +} + +/// Builds the successful DELETE response. +#[must_use] +pub fn delete_success_response() -> DavResponse { + DavResponse::empty(StatusCode::NO_CONTENT) +} + +/// Compares DAV resource identity while ignoring collection trailing slashes. +#[must_use] +pub fn same_resource_path(left: &str, right: &str) -> bool { + resource_identity_path(left) == resource_identity_path(right) +} + +/// Returns whether `child` is strictly below `parent` on a DAV path-segment boundary. +#[must_use] +pub fn is_descendant_path(parent: &str, child: &str) -> bool { + let parent = resource_identity_path(parent); + let child = resource_identity_path(child); + if parent == "/" || parent == child { + return false; + } + child.starts_with(&format!("{parent}/")) +} + +/// Re-roots a canonical descendant path from one mutation tree to another. +/// +/// Only a complete path-segment prefix is stripped. An unmatched path is attached to the +/// destination root unchanged so callers can preserve the original hierarchy in failure paths. +#[must_use] +pub fn replace_relative_prefix( + path: &str, + source_prefix: &str, + destination_prefix: &str, +) -> String { + let source_prefix = source_prefix.trim_end_matches('/'); + let destination_prefix = destination_prefix.trim_end_matches('/'); + let suffix = path + .strip_prefix(source_prefix) + .filter(|suffix| suffix.is_empty() || suffix.starts_with('/')) + .unwrap_or(path); + if suffix.is_empty() { + format!("{destination_prefix}/") + } else { + format!("{destination_prefix}{suffix}") + } +} + +/// Builds the cache-safe 201/204 response selected by destination existence. +#[must_use] +pub fn mutation_success_response(destination_existed: bool) -> DavResponse { + let status = if destination_existed { + StatusCode::NO_CONTENT + } else { + StatusCode::CREATED + }; + no_store_empty_response(status) +} + +/// Builds a 207 response for typed recursive mutation failures. +pub fn mutation_multistatus_response( + prefix: &str, + failures: &[DavMutationFailure], +) -> Result { + let items = failures + .iter() + .map(|failure| { + let item = DavMultiStatusItem::status( + href_for_dav_path(prefix, &failure.path), + failure.status, + ); + if failure.status == StatusCode::LOCKED.as_u16() { + let lock_path = failure.lock_path.as_ref().unwrap_or(&failure.path); + item.with_error(DavErrorCondition::LockTokenSubmitted { + href: href_for_dav_path(prefix, lock_path), + }) + } else { + item + } + }) + .collect(); + let body = dav_multistatus_element(items).to_bytes()?; + let mut response = DavResponse::bytes(StatusCode::MULTI_STATUS, body); + response.headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/xml; charset=utf-8"), + ); + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + Ok(response) +} + +/// Normalizes a DAV resource identity by removing collection trailing slashes. +#[must_use] +pub fn resource_identity_path(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + "/".to_string() + } else { + trimmed.to_string() + } +} diff --git a/crates/aster_forge_webdav/src/response.rs b/crates/aster_forge_webdav/src/response.rs new file mode 100644 index 0000000..4d4caaf --- /dev/null +++ b/crates/aster_forge_webdav/src/response.rs @@ -0,0 +1,410 @@ +//! Transport-neutral WebDAV response model and download response planning. + +use std::time::SystemTime; + +use aster_forge_utils::http_range::{HttpByteRange, HttpRangeError, parse_single_byte_range}; +use aster_forge_utils::http_validators::{ + format_http_date, http_date_epoch_seconds, parse_http_date, +}; +use bytes::Bytes; +use http::header::{ + ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, + CONTENT_TYPE, ETAG, IF_RANGE, LAST_MODIFIED, RANGE, +}; +use http::{HeaderMap, HeaderValue, StatusCode}; + +use crate::{ + DavBackendError, DavBackendErrorKind, DavContentStream, DavErrorCondition, DavPrecondition, + DavProtocolError, DavProtocolErrorKind, DavXmlElement, DavXmlError, dav_error_element, +}; + +/// Methods advertised by the product-neutral DAV protocol engine. +pub const DAV_ALLOW_HEADER: &str = "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT, VERSION-CONTROL"; + +/// Failure while enforcing a request body policy in the transport adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DavBodyError { + #[error("failed to read WebDAV request body")] + ReadFailed, + #[error("WebDAV XML body is too large")] + XmlTooLarge, + #[error("WebDAV method does not accept a request body")] + BodyNotAllowed, +} + +/// Whether a successful GET/HEAD response needs content from the product backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavDownloadBody { + /// The response has no body because it is a HEAD, 304, or 416 response. + Empty, + /// Stream the complete representation. + Full, + /// Stream only the selected representation range. + Range(HttpByteRange), +} + +/// A complete response shell plus the storage read selected by the protocol layer. +pub struct DavDownloadPlan { + pub response: DavResponse, + pub body: DavDownloadBody, +} + +/// Failure while building a product-neutral download response plan. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DavDownloadPlanError { + #[error(transparent)] + Protocol(#[from] DavProtocolError), + #[error("invalid WebDAV download representation metadata")] + InvalidRepresentation, +} + +/// WebDAV response body before transport adaptation. +pub enum DavResponseBody { + Empty, + Bytes(Bytes), + Stream(DavContentStream), +} + +/// Status, headers, and body produced by the protocol layer. +pub struct DavResponse { + pub status: StatusCode, + pub headers: HeaderMap, + pub body: DavResponseBody, +} + +impl DavResponse { + /// Creates an empty response. + #[must_use] + pub fn empty(status: StatusCode) -> Self { + Self { + status, + headers: HeaderMap::new(), + body: DavResponseBody::Empty, + } + } + + /// Creates a byte response. + #[must_use] + pub fn bytes(status: StatusCode, body: impl Into) -> Self { + Self { + status, + headers: HeaderMap::new(), + body: DavResponseBody::Bytes(body.into()), + } + } +} + +pub(crate) fn xml_document_response( + status: StatusCode, + root: DavXmlElement, +) -> Result { + let mut response = DavResponse::bytes(status, root.to_bytes()?); + response.headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/xml; charset=utf-8"), + ); + if status.is_client_error() || status.is_server_error() { + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + } + Ok(response) +} + +pub(crate) fn text_document_response(status: StatusCode, body: impl Into) -> DavResponse { + let mut response = DavResponse::bytes(status, body.into()); + response.headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + if status.is_client_error() || status.is_server_error() { + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + } + response +} + +pub(crate) fn xml_request_error_response( + error: DavXmlError, + invalid_grammar_message: &'static str, +) -> Result { + match error { + DavXmlError::ExternalEntity => xml_document_response( + StatusCode::FORBIDDEN, + dav_error_element(&DavErrorCondition::NoExternalEntities), + ), + DavXmlError::TooLarge => Ok(text_document_response( + StatusCode::PAYLOAD_TOO_LARGE, + "WebDAV XML body too large", + )), + DavXmlError::TooDeep | DavXmlError::Malformed => Ok(text_document_response( + StatusCode::BAD_REQUEST, + "Invalid XML body", + )), + DavXmlError::InvalidGrammar => Ok(text_document_response( + StatusCode::BAD_REQUEST, + invalid_grammar_message, + )), + } +} + +/// Maps a protocol parsing/precondition failure to its transport-neutral response. +#[must_use] +pub fn protocol_error_response(error: &DavProtocolError) -> DavResponse { + match error.kind() { + DavProtocolErrorKind::BadRequest => text_document_response(error.status(), error.message()), + DavProtocolErrorKind::PreconditionFailed => no_store_empty_response(error.status()), + } +} + +/// Maps a classified product backend failure to the WebDAV status contract. +#[must_use] +pub fn backend_error_response(error: &DavBackendError) -> DavResponse { + let status = match error.kind { + DavBackendErrorKind::NotFound => StatusCode::NOT_FOUND, + DavBackendErrorKind::Forbidden => StatusCode::FORBIDDEN, + DavBackendErrorKind::Conflict | DavBackendErrorKind::AlreadyExists => StatusCode::CONFLICT, + DavBackendErrorKind::InsufficientStorage => StatusCode::INSUFFICIENT_STORAGE, + DavBackendErrorKind::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + DavBackendErrorKind::Locked => StatusCode::LOCKED, + DavBackendErrorKind::InvalidInput => StatusCode::BAD_REQUEST, + DavBackendErrorKind::Unsupported => StatusCode::METHOD_NOT_ALLOWED, + DavBackendErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR, + }; + no_store_empty_response(status) +} + +pub(crate) fn no_store_empty_response(status: StatusCode) -> DavResponse { + let mut response = DavResponse::empty(status); + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// Builds the response to a DAV `OPTIONS` request. +#[must_use] +pub fn options_response() -> DavResponse { + let mut response = DavResponse::empty(StatusCode::OK); + response + .headers + .insert(ALLOW, HeaderValue::from_static(DAV_ALLOW_HEADER)); + response + .headers + .insert("DAV", HeaderValue::from_static("1, 2, version-control")); + response + .headers + .insert("MS-Author-Via", HeaderValue::from_static("DAV")); + response +} + +/// Builds the response for an unsupported HTTP/WebDAV method. +#[must_use] +pub fn method_not_allowed_response() -> DavResponse { + let mut response = DavResponse::empty(StatusCode::METHOD_NOT_ALLOWED); + response + .headers + .insert(ALLOW, HeaderValue::from_static(DAV_ALLOW_HEADER)); + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// Builds the protocol response for a transport body-policy failure. +#[must_use] +pub fn body_error_response(error: DavBodyError) -> DavResponse { + let (status, body) = match error { + DavBodyError::ReadFailed => (StatusCode::BAD_REQUEST, Some("Failed to read request body")), + DavBodyError::XmlTooLarge => ( + StatusCode::PAYLOAD_TOO_LARGE, + Some("WebDAV XML body too large"), + ), + DavBodyError::BodyNotAllowed => (StatusCode::UNSUPPORTED_MEDIA_TYPE, None), + }; + let mut response = match body { + Some(body) => DavResponse::bytes(status, body), + None => DavResponse::empty(status), + }; + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + if body.is_some() { + response.headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + } + response +} + +/// Builds the GET/HEAD response and storage-read plan after product metadata has been resolved. +pub fn plan_download_response( + headers: &HeaderMap, + head_only: bool, + content_length: u64, + content_type: &str, + etag: Option<&str>, + last_modified: SystemTime, +) -> Result { + match crate::evaluate_http_download_preconditions(headers, etag, Some(last_modified))? { + DavPrecondition::Proceed => {} + DavPrecondition::NotModified => { + let mut response = DavResponse::empty(StatusCode::NOT_MODIFIED); + insert_validators(&mut response.headers, etag, last_modified)?; + return Ok(DavDownloadPlan { + response, + body: DavDownloadBody::Empty, + }); + } + } + + let range = if head_only || !if_range_matches(headers, etag, last_modified) { + None + } else if let Some(value) = headers.get(RANGE) { + let Ok(raw) = value.to_str() else { + return Ok(range_not_satisfiable_plan(content_length)); + }; + match parse_single_byte_range(raw, content_length) { + Ok(range) => Some(range), + Err(HttpRangeError::UnsupportedUnit | HttpRangeError::MultipleRangesUnsupported) => { + None + } + Err( + HttpRangeError::Malformed + | HttpRangeError::InvalidNumber + | HttpRangeError::EmptyRepresentation + | HttpRangeError::Unsatisfiable, + ) => return Ok(range_not_satisfiable_plan(content_length)), + } + } else { + None + }; + + let (status, response_length, body) = match range { + Some(range) => ( + StatusCode::PARTIAL_CONTENT, + range.length(), + DavDownloadBody::Range(range), + ), + None if head_only => (StatusCode::OK, content_length, DavDownloadBody::Empty), + None => (StatusCode::OK, content_length, DavDownloadBody::Full), + }; + let mut response = DavResponse::empty(status); + response + .headers + .insert(CONTENT_LENGTH, header_value(&response_length.to_string())?); + response + .headers + .insert(CONTENT_TYPE, header_value(content_type)?); + response + .headers + .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); + response + .headers + .insert(CONTENT_ENCODING, HeaderValue::from_static("identity")); + if let Some(range) = range { + response + .headers + .insert(CONTENT_RANGE, header_value(&range.content_range_header())?); + } + insert_validators(&mut response.headers, etag, last_modified)?; + + Ok(DavDownloadPlan { response, body }) +} + +fn if_range_matches(headers: &HeaderMap, etag: Option<&str>, last_modified: SystemTime) -> bool { + let Some(value) = headers.get(IF_RANGE) else { + return true; + }; + let Ok(raw) = value.to_str() else { + return false; + }; + let raw = raw.trim(); + if raw.starts_with('"') + || raw + .get(..2) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("W/")) + { + return strong_if_range_etag_matches(raw, etag); + } + parse_http_date(raw) + .is_ok_and(|date| http_date_epoch_seconds(date) == http_date_epoch_seconds(last_modified)) +} + +fn strong_if_range_etag_matches(candidate: &str, current: Option<&str>) -> bool { + if candidate + .get(..2) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("W/")) + { + return false; + } + let Some(candidate) = candidate + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .filter(|value| !value.contains('"')) + else { + return false; + }; + let Some(current) = current else { + return false; + }; + if current + .trim() + .get(..2) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("W/")) + { + return false; + } + let current = current.trim(); + let current = current + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(current); + candidate == current +} + +/// Builds the response required when a byte range cannot be served. +#[must_use] +pub fn range_not_satisfiable_response(content_length: u64) -> DavResponse { + let mut response = DavResponse::empty(StatusCode::RANGE_NOT_SATISFIABLE); + response.headers.insert( + CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes */{content_length}")) + .unwrap_or_else(|_| HeaderValue::from_static("bytes */0")), + ); + response + .headers + .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +fn range_not_satisfiable_plan(content_length: u64) -> DavDownloadPlan { + DavDownloadPlan { + response: range_not_satisfiable_response(content_length), + body: DavDownloadBody::Empty, + } +} + +fn insert_validators( + headers: &mut HeaderMap, + etag: Option<&str>, + last_modified: SystemTime, +) -> Result<(), DavDownloadPlanError> { + headers.insert( + LAST_MODIFIED, + header_value(&format_http_date(last_modified))?, + ); + if let Some(etag) = etag { + headers.insert(ETAG, header_value(&format!("\"{etag}\""))?); + } + Ok(()) +} + +fn header_value(value: &str) -> Result { + HeaderValue::from_str(value).map_err(|_| DavDownloadPlanError::InvalidRepresentation) +} diff --git a/crates/aster_forge_webdav/src/xml.rs b/crates/aster_forge_webdav/src/xml.rs new file mode 100644 index 0000000..212c76e --- /dev/null +++ b/crates/aster_forge_webdav/src/xml.rs @@ -0,0 +1,574 @@ +//! WebDAV XML grammar and representation boundary. +//! +//! The concrete XML implementation is intentionally private to this module. Products consume +//! WebDAV-specific request models and [`DavXmlElement`] instead of depending on an XML crate. + +use std::collections::BTreeMap; +use std::io::Read; + +use aster_forge_xml::{ + BorrowedDocument, ElementRef, Error as ForgeXmlError, NodeRef, OwnedDocument, ParseOptions, + XmlSafetyError, XmlSafetyPolicy, XmlStreamWriter, XmlWriteAttribute, +}; + +const DAV_NAMESPACE: &str = "DAV:"; + +/// XML failure returned by the WebDAV grammar boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DavXmlError { + /// The document declares a DTD or entity. + #[error("XML external entity declarations are not allowed")] + ExternalEntity, + /// The document exceeds the configured nesting depth. + #[error("XML nesting depth exceeds the configured limit")] + TooDeep, + /// The request XML exceeds an input or decoded-text size limit. + #[error("XML input exceeds the configured size limit")] + TooLarge, + /// The document is malformed or is not a single-root document. + #[error("malformed XML input")] + Malformed, + /// The document is well-formed XML but violates the method grammar. + #[error("invalid WebDAV XML grammar")] + InvalidGrammar, +} + +impl From for DavXmlError { + fn from(error: XmlSafetyError) -> Self { + match error { + XmlSafetyError::ExternalEntity => Self::ExternalEntity, + XmlSafetyError::TooDeep => Self::TooDeep, + XmlSafetyError::InputTooLarge | XmlSafetyError::TextTooLarge => Self::TooLarge, + XmlSafetyError::InvalidPolicy + | XmlSafetyError::OutputTooLarge + | XmlSafetyError::TooManyElements + | XmlSafetyError::TooManyAttributes + | XmlSafetyError::TooManyEvents + | XmlSafetyError::InvalidEncoding + | XmlSafetyError::Malformed => Self::Malformed, + } + } +} + +/// XML content owned by the WebDAV boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DavXmlNode { + /// Child element. + Element(DavXmlElement), + /// Escaped character data. + Text(String), + /// CDATA content. + CData(String), + /// Comment content. + Comment(String), + /// Processing instruction. + ProcessingInstruction(String, Option), +} + +/// Owned DAV element used for persisted subtrees and response composition. +/// +/// Known request grammars traverse the source-backed `aster_forge_xml` arena directly and only +/// materialize the owner or property subtrees that must cross the backend boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavXmlElement { + /// Local element name. + pub name: String, + /// Lexical prefix, when present. + pub prefix: Option, + /// Resolved namespace URI, when present. + pub namespace: Option, + /// In-scope namespace declarations keyed by prefix; an empty key is the default namespace. + pub namespaces: BTreeMap, + /// Element attributes in their lexical form. + pub attributes: BTreeMap, + /// Ordered child content. + pub children: Vec, +} + +impl DavXmlElement { + /// Creates an element from a lexical QName such as `D:href`. + #[must_use] + pub fn new(name: &str) -> Self { + let (prefix, local_name) = name + .split_once(':') + .map_or((None, name), |(prefix, local)| { + (Some(prefix.to_owned()), local) + }); + Self { + name: local_name.to_owned(), + prefix, + namespace: None, + namespaces: BTreeMap::new(), + attributes: BTreeMap::new(), + children: Vec::new(), + } + } + + /// Creates a `DAV:` element using the conventional `D` prefix. + #[must_use] + pub fn dav(local_name: &str) -> Self { + let mut element = Self::new(&format!("D:{local_name}")); + element.namespace = Some(DAV_NAMESPACE.to_owned()); + element + } + + /// Parses one bounded XML element. + pub fn parse(bytes: &[u8]) -> Result { + parse_element(bytes) + } + + /// Parses one bounded XML element from a reader. + pub fn parse_reader(reader: impl Read) -> Result { + let options = webdav_parse_options(); + let document = OwnedDocument::from_reader_with_options(reader, &options) + .map_err(map_forge_xml_error)?; + Ok(element_from_forge(document.root())) + } + + /// Serializes the element as UTF-8 XML bytes. + pub fn to_bytes(&self) -> Result, DavXmlError> { + let mut writer = XmlStreamWriter::new(Vec::new()).map_err(map_forge_xml_error)?; + write_element(&mut writer, self, &BTreeMap::new())?; + writer.finish().map_err(map_forge_xml_error) + } + + /// Iterates over direct child elements while ignoring text, comments, and CDATA. + pub fn child_elements(&self) -> impl Iterator { + self.children.iter().filter_map(|child| match child { + DavXmlNode::Element(element) => Some(element), + DavXmlNode::Text(_) + | DavXmlNode::CData(_) + | DavXmlNode::Comment(_) + | DavXmlNode::ProcessingInstruction(_, _) => None, + }) + } + + /// Returns concatenated direct text and CDATA content. + #[must_use] + pub fn text(&self) -> Option { + let text = self + .children + .iter() + .filter_map(|child| match child { + DavXmlNode::Text(text) | DavXmlNode::CData(text) => Some(text.as_str()), + _ => None, + }) + .collect::(); + (!text.is_empty()).then_some(text) + } +} + +/// Property name selected by PROPFIND. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavRequestedProperty { + /// Local property name. + pub name: String, + /// Resolved namespace URI. + pub namespace: Option, + /// Client-supplied lexical prefix. + pub prefix: Option, +} + +/// Parsed PROPFIND request selector. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DavPropfindRequest { + /// All live/dead properties plus optional explicit properties. + AllProp { + /// Additional requested properties. + include: Vec, + }, + /// Property names without values. + PropName, + /// Explicit property selection. + Prop(Vec), +} + +/// One ordered PROPPATCH operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavPropertyPatchRequest { + /// Whether the operation sets rather than removes the property. + pub set: bool, + /// Property value/name. + pub property: DavPropertyPatchValue, +} + +/// Validated property element carried by PROPPATCH. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavPropertyPatchValue { + /// Local property name. + pub name: String, + /// Resolved namespace URI. + pub namespace: Option, + /// Lexical prefix. + pub prefix: Option, + /// Standalone validated element, including inherited `xml:lang` when needed. + pub element: DavXmlElement, +} + +/// Parsed LOCK creation body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavLockRequestBody { + /// Whether the requested lock scope is shared. + pub shared: bool, + /// Optional owner element, preserved for discovery and persistence. + pub owner: Option, +} + +/// Parses a PROPFIND body. An absent body selects `allprop`. +pub fn parse_propfind_request(body: &[u8]) -> Result { + if body.is_empty() { + return Ok(DavPropfindRequest::AllProp { + include: Vec::new(), + }); + } + let document = parse_document(body)?; + let root = document.root(); + if !is_dav_element(root, "propfind") { + return Err(DavXmlError::InvalidGrammar); + } + + let mut kind = None; + let mut include = Vec::new(); + let mut include_seen = false; + for child in root.child_elements() { + if is_dav_element(child, "propname") { + if kind.is_some() { + return Err(DavXmlError::InvalidGrammar); + } + kind = Some(DavPropfindRequest::PropName); + } else if is_dav_element(child, "allprop") { + if kind.is_some() { + return Err(DavXmlError::InvalidGrammar); + } + kind = Some(DavPropfindRequest::AllProp { + include: Vec::new(), + }); + } else if is_dav_element(child, "include") { + if include_seen { + return Err(DavXmlError::InvalidGrammar); + } + include_seen = true; + include.extend(child.child_elements().map(requested_property)); + } else if is_dav_element(child, "prop") { + if kind.is_some() { + return Err(DavXmlError::InvalidGrammar); + } + kind = Some(DavPropfindRequest::Prop( + child.child_elements().map(requested_property).collect(), + )); + } + } + + match kind { + Some(DavPropfindRequest::AllProp { .. }) => Ok(DavPropfindRequest::AllProp { include }), + Some(kind) if !include_seen => Ok(kind), + _ => Err(DavXmlError::InvalidGrammar), + } +} + +/// Parses an ordered PROPPATCH request. +pub fn parse_proppatch_request(body: &[u8]) -> Result, DavXmlError> { + let document = parse_document(body)?; + let root = document.root(); + if !is_dav_element(root, "propertyupdate") { + return Err(DavXmlError::InvalidGrammar); + } + let root_lang = xml_lang_value(root).map(str::to_owned); + let mut patches = Vec::new(); + for action in root.child_elements() { + let set = if is_dav_element(action, "set") { + true + } else if is_dav_element(action, "remove") { + false + } else { + // RFC 4918 section 17: unknown extension elements are ignored with their subtree. + continue; + }; + let action_lang = xml_lang_value(action).or(root_lang.as_deref()); + let dav_children = action + .child_elements() + .filter(|child| child.namespace() == Some(DAV_NAMESPACE)) + .collect::>(); + if !matches!(dav_children.as_slice(), [child] if is_dav_element(*child, "prop")) { + return Err(DavXmlError::InvalidGrammar); + } + let prop_container = dav_children[0]; + let container_lang = xml_lang_value(prop_container).or(action_lang); + for property in prop_container.child_elements() { + let mut element = element_from_forge(property); + let inherited_lang = xml_lang_value(property).or(container_lang); + if let Some(lang) = inherited_lang.filter(|lang| !lang.is_empty()) { + element + .attributes + .entry("xml:lang".to_owned()) + .or_insert_with(|| lang.to_owned()); + } + patches.push(DavPropertyPatchRequest { + set, + property: DavPropertyPatchValue { + name: element.name.clone(), + namespace: element.namespace.clone(), + prefix: element.prefix.clone(), + element, + }, + }); + } + } + if patches.is_empty() { + return Err(DavXmlError::InvalidGrammar); + } + Ok(patches) +} + +/// Parses a LOCK creation body. +pub fn parse_lock_request(body: &[u8]) -> Result { + let document = parse_document(body)?; + let root = document.root(); + if !is_dav_element(root, "lockinfo") { + return Err(DavXmlError::InvalidGrammar); + } + let mut shared = None; + let mut write_lock = false; + let mut owner = None; + for child in root.child_elements() { + if is_dav_element(child, "lockscope") { + if shared.is_some() { + return Err(DavXmlError::InvalidGrammar); + } + let children = child + .child_elements() + .filter(|scope| scope.namespace() == Some(DAV_NAMESPACE)) + .collect::>(); + shared = match children.as_slice() { + [scope] if is_dav_element(*scope, "exclusive") => Some(false), + [scope] if is_dav_element(*scope, "shared") => Some(true), + _ => return Err(DavXmlError::InvalidGrammar), + }; + } else if is_dav_element(child, "locktype") { + if write_lock { + return Err(DavXmlError::InvalidGrammar); + } + let children = child + .child_elements() + .filter(|kind| kind.namespace() == Some(DAV_NAMESPACE)) + .collect::>(); + if !matches!(children.as_slice(), [kind] if is_dav_element(*kind, "write")) { + return Err(DavXmlError::InvalidGrammar); + } + write_lock = true; + } else if is_dav_element(child, "owner") { + if owner.is_some() { + return Err(DavXmlError::InvalidGrammar); + } + owner = Some(element_from_forge(child)); + } + } + match (shared, write_lock) { + (Some(shared), true) => Ok(DavLockRequestBody { shared, owner }), + _ => Err(DavXmlError::InvalidGrammar), + } +} + +/// Returns the QName of a bounded REPORT root. +pub fn parse_report_root(body: &[u8]) -> Result { + let document = parse_document(body)?; + Ok(requested_property(document.root())) +} + +fn parse_element(bytes: &[u8]) -> Result { + let document = parse_document(bytes)?; + Ok(element_from_forge(document.root())) +} + +fn parse_document(bytes: &[u8]) -> Result, DavXmlError> { + // The Forge parser applies the WebDAV safety policy while building its source-backed arena. + // A separate validator pass here would scan every request twice. + BorrowedDocument::parse_with_options(bytes, &webdav_parse_options()) + .map_err(map_forge_xml_error) +} + +fn is_dav_element>(element: ElementRef<'_, S>, local_name: &str) -> bool { + element.name() == local_name && element.namespace() == Some(DAV_NAMESPACE) +} + +fn requested_property>(element: ElementRef<'_, S>) -> DavRequestedProperty { + DavRequestedProperty { + name: element.name().to_owned(), + namespace: element.namespace().map(str::to_owned), + prefix: element.prefix().map(str::to_owned), + } +} + +fn xml_lang_value<'document, S: AsRef<[u8]>>( + element: ElementRef<'document, S>, +) -> Option<&'document str> { + element.attribute("xml:lang") +} + +fn webdav_parse_options() -> ParseOptions { + // Preserve the established WebDAV XML boundary: formatting whitespace is ignored and retained + // text is trimmed before WebDAV grammar evaluation or dead-property persistence. + ParseOptions::new() + .safety_policy(XmlSafetyPolicy::untrusted()) + .trim_whitespace(true) +} + +fn map_forge_xml_error(error: ForgeXmlError) -> DavXmlError { + match error { + ForgeXmlError::Safety(error) => error.into(), + ForgeXmlError::InvalidXml(_) | ForgeXmlError::InvalidData(_) | ForgeXmlError::Io(_) => { + DavXmlError::Malformed + } + } +} + +fn element_from_forge>(element: ElementRef<'_, S>) -> DavXmlElement { + let mut namespaces = BTreeMap::new(); + match (element.prefix(), element.namespace()) { + (Some(prefix), Some(namespace)) if prefix != "xml" => { + namespaces.insert(prefix.to_owned(), namespace.to_owned()); + } + (None, Some(namespace)) => { + namespaces.insert(String::new(), namespace.to_owned()); + } + // This owned subtree may later be embedded under a default namespace. Declaring the + // empty namespace keeps an originally unqualified element unqualified. + (None, None) => { + namespaces.insert(String::new(), String::new()); + } + _ => {} + } + + let mut attributes = BTreeMap::new(); + for attribute in element.attributes() { + if let (Some(prefix), Some(namespace)) = (attribute.prefix(), attribute.namespace()) + && prefix != "xml" + { + namespaces + .entry(prefix.to_owned()) + .or_insert_with(|| namespace.to_owned()); + } + attributes.insert( + attribute.qualified_name().to_owned(), + attribute.value().to_owned(), + ); + } + + DavXmlElement { + name: element.name().to_owned(), + prefix: element.prefix().map(str::to_owned), + namespace: element.namespace().map(str::to_owned), + namespaces, + attributes, + children: element + .children() + .map(|child| match child { + NodeRef::Element(element) => DavXmlNode::Element(element_from_forge(element)), + NodeRef::Text(text) => DavXmlNode::Text(text.to_owned()), + NodeRef::CData(text) => DavXmlNode::CData(text.to_owned()), + NodeRef::Comment(text) => DavXmlNode::Comment(text.to_owned()), + NodeRef::ProcessingInstruction(instruction) => DavXmlNode::ProcessingInstruction( + instruction.target.to_owned(), + instruction.content.map(str::to_owned), + ), + }) + .collect(), + } +} + +fn write_element( + writer: &mut XmlStreamWriter>, + element: &DavXmlElement, + inherited_namespaces: &BTreeMap, +) -> Result<(), DavXmlError> { + let qualified_name = element.prefix.as_ref().map_or_else( + || element.name.clone(), + |prefix| format!("{prefix}:{}", element.name), + ); + let mut namespaces = inherited_namespaces.clone(); + let mut attributes = BTreeMap::new(); + for (prefix, namespace) in &element.namespaces { + if namespaces.get(prefix) != Some(namespace) { + let name = if prefix.is_empty() { + "xmlns".to_owned() + } else { + format!("xmlns:{prefix}") + }; + attributes.insert(name, namespace.clone()); + } + namespaces.insert(prefix.clone(), namespace.clone()); + } + attributes.extend(element.attributes.clone()); + for (name, namespace) in &attributes { + if let Some(prefix) = namespace_declaration_prefix(name) { + namespaces.insert(prefix.to_owned(), namespace.clone()); + } + } + + if let Some(namespace) = &element.namespace { + let prefix = element.prefix.as_deref().unwrap_or(""); + if namespaces.get(prefix).map(String::as_str) != Some(namespace) { + let binding_name = if prefix.is_empty() { + "xmlns".to_owned() + } else { + format!("xmlns:{prefix}") + }; + match attributes.get(&binding_name) { + Some(binding) if binding != namespace => return Err(DavXmlError::Malformed), + Some(_) => {} + None => { + attributes.insert(binding_name, namespace.clone()); + } + } + namespaces.insert(prefix.to_owned(), namespace.clone()); + } + } else if element.prefix.is_none() + && namespaces + .get("") + .is_some_and(|namespace| !namespace.is_empty()) + { + match attributes.get("xmlns") { + Some(namespace) if !namespace.is_empty() => return Err(DavXmlError::Malformed), + Some(_) => {} + None => { + attributes.insert("xmlns".to_owned(), String::new()); + } + } + namespaces.insert(String::new(), String::new()); + } + + let write_attributes = attributes + .iter() + .map(|(name, value)| XmlWriteAttribute::new(name, value)); + if element.children.is_empty() { + writer + .empty_element(&qualified_name, write_attributes) + .map_err(map_forge_xml_error)?; + return Ok(()); + } + writer + .start_element(&qualified_name, write_attributes) + .map_err(map_forge_xml_error)?; + for child in &element.children { + match child { + DavXmlNode::Element(element) => write_element(writer, element, &namespaces)?, + DavXmlNode::Text(text) => writer.text(text).map_err(map_forge_xml_error)?, + DavXmlNode::CData(text) => writer.cdata(text).map_err(map_forge_xml_error)?, + DavXmlNode::Comment(text) => writer.comment(text).map_err(map_forge_xml_error)?, + DavXmlNode::ProcessingInstruction(target, content) => { + writer + .processing_instruction(target, content.as_deref()) + .map_err(map_forge_xml_error)?; + } + } + } + writer.end_element().map_err(map_forge_xml_error) +} + +fn namespace_declaration_prefix(name: &str) -> Option<&str> { + if name == "xmlns" { + Some("") + } else { + name.strip_prefix("xmlns:") + } +} diff --git a/crates/aster_forge_webdav/src/xml_response.rs b/crates/aster_forge_webdav/src/xml_response.rs new file mode 100644 index 0000000..28fe71e --- /dev/null +++ b/crates/aster_forge_webdav/src/xml_response.rs @@ -0,0 +1,435 @@ +//! Product-neutral WebDAV XML response grammar. + +use std::time::Duration; + +use http::StatusCode; + +use crate::{DavRequestedProperty, DavXmlElement, DavXmlNode, encode_href}; + +/// One `` group in a multistatus response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavPropStat { + /// HTTP status applying to every property in this group. + pub status: u16, + /// Ordered property elements. + pub properties: Vec, +} + +/// WebDAV precondition or postcondition carried inside ``. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DavErrorCondition { + /// RFC 4918 `no-external-entities` precondition. + NoExternalEntities, + /// RFC 4918 `lock-token-submitted` precondition. + LockTokenSubmitted { + /// Encoded resource href whose token must be submitted. + href: String, + }, + /// RFC 4918 `lock-token-matches-request-uri` precondition. + LockTokenMatchesRequestUri, + /// RFC 4918 `propfind-finite-depth` precondition. + PropfindFiniteDepth, +} + +/// One `` entry in a multistatus response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavMultiStatusItem { + /// Encoded resource href. + pub href: String, + /// Resource-wide status, used by COPY/MOVE/DELETE failures. + pub status: Option, + /// Property status groups, used by PROPFIND and PROPPATCH. + pub propstats: Vec, + /// Optional WebDAV condition accompanying the resource status. + pub error: Option, +} + +impl DavMultiStatusItem { + /// Creates a property response entry. + #[must_use] + pub fn properties(href: impl Into, propstats: Vec) -> Self { + Self { + href: href.into(), + status: None, + propstats, + error: None, + } + } + + /// Creates a resource-wide status response entry. + #[must_use] + pub fn status(href: impl Into, status: u16) -> Self { + Self { + href: href.into(), + status: Some(status), + propstats: Vec::new(), + error: None, + } + } + + /// Attaches a WebDAV error condition. + #[must_use] + pub fn with_error(mut self, error: DavErrorCondition) -> Self { + self.error = Some(error); + self + } +} + +/// Protocol-visible lock values used to render `lockdiscovery`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavLockXml { + /// Raw lock token; the XML writer percent-encodes it for the href. + pub token: String, + /// Optional validated owner element. + pub owner: Option, + /// Negotiated timeout, or `None` for `Infinite`. + pub timeout: Option, + /// Whether the lock is shared rather than exclusive. + pub shared: bool, + /// Whether the lock depth is infinity rather than zero. + pub deep: bool, + /// Encoded href of the lock root. + pub root_href: String, +} + +/// One version entry in a DeltaV version-tree response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavVersionXml { + /// Encoded version href. + pub href: String, + /// Protocol-visible version name. + pub version_name: String, + /// Creator display name. + pub creator: String, + /// Content length. + pub content_length: i64, + /// HTTP-date formatted modification time. + pub last_modified: String, +} + +/// Creates a `DAV:` element using the conventional `D` prefix. +#[must_use] +pub fn dav_element(local_name: &str) -> DavXmlElement { + DavXmlElement::dav(local_name) +} + +/// Creates a `DAV:` element containing one text node. +#[must_use] +pub fn dav_text_element(local_name: &str, text: impl Into) -> DavXmlElement { + let mut element = dav_element(local_name); + element.children.push(DavXmlNode::Text(text.into())); + element +} + +/// Creates an empty property element using its expanded name and preferred prefix. +#[must_use] +pub fn dav_property_name_element(name: &DavRequestedProperty) -> DavXmlElement { + property_element(name, None) +} + +/// Creates a property element containing one text node. +#[must_use] +pub fn dav_property_text_element( + name: &DavRequestedProperty, + text: impl Into, +) -> DavXmlElement { + property_element(name, Some(DavXmlNode::Text(text.into()))) +} + +/// Creates a property element containing one child element. +#[must_use] +pub fn dav_property_child_element( + name: &DavRequestedProperty, + child: DavXmlElement, +) -> DavXmlElement { + property_element(name, Some(DavXmlNode::Element(child))) +} + +/// Reconstructs a persisted dead property under the requested lexical QName. +/// +/// Matching validated XML contributes its attributes and children. Legacy malformed or +/// mismatched values are emitted as escaped text instead of response markup. +#[must_use] +pub fn dav_dead_property_element( + stored_name: &DavRequestedProperty, + requested_name: Option<&DavRequestedProperty>, + stored_xml: Option<&[u8]>, +) -> DavXmlElement { + let output_name = requested_name.unwrap_or(stored_name); + let mut output = property_element(output_name, None); + let Some(stored_xml) = stored_xml.filter(|xml| !xml.is_empty()) else { + return output; + }; + if let Ok(stored) = DavXmlElement::parse(stored_xml) + && stored.name == stored_name.name + && stored.namespace == stored_name.namespace + { + for (key, value) in stored.attributes { + if key.starts_with("xmlns") { + continue; + } + let key = if key == "lang" { "xml:lang" } else { &key }; + output.attributes.entry(key.to_owned()).or_insert(value); + } + output.children = stored.children; + } else { + output.children.push(DavXmlNode::Text( + String::from_utf8_lossy(stored_xml).into_owned(), + )); + } + output +} + +/// Creates a WebDAV HTTP status-line element. +#[must_use] +pub fn dav_status_element(status: u16) -> DavXmlElement { + let status = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + dav_text_element( + "status", + format!( + "HTTP/1.1 {} {}", + status.as_u16(), + status.canonical_reason().unwrap_or("Unknown"), + ), + ) +} + +/// Creates a complete `` document. +#[must_use] +pub fn dav_error_element(condition: &DavErrorCondition) -> DavXmlElement { + let mut error = dav_element("error"); + declare_dav_namespace(&mut error); + error + .children + .push(DavXmlNode::Element(error_condition_element(condition))); + error +} + +/// Creates one `` element. +#[must_use] +pub fn dav_propstat_element(propstat: DavPropStat) -> DavXmlElement { + let mut element = dav_element("propstat"); + let mut properties = dav_element("prop"); + properties + .children + .extend(propstat.properties.into_iter().map(DavXmlNode::Element)); + element.children.push(DavXmlNode::Element(properties)); + element + .children + .push(DavXmlNode::Element(dav_status_element(propstat.status))); + element +} + +/// Creates one `` element. +#[must_use] +pub fn dav_response_element(item: DavMultiStatusItem) -> DavXmlElement { + let mut response = dav_element("response"); + response + .children + .push(DavXmlNode::Element(dav_text_element("href", item.href))); + response.children.extend( + item.propstats + .into_iter() + .map(dav_propstat_element) + .map(DavXmlNode::Element), + ); + if let Some(status) = item.status { + response + .children + .push(DavXmlNode::Element(dav_status_element(status))); + } + if let Some(error) = item.error { + let mut error_element = dav_element("error"); + error_element + .children + .push(DavXmlNode::Element(error_condition_element(&error))); + response.children.push(DavXmlNode::Element(error_element)); + } + response +} + +/// Creates a complete `` document. +#[must_use] +pub fn dav_multistatus_element(items: Vec) -> DavXmlElement { + let mut multistatus = dav_element("multistatus"); + declare_dav_namespace(&mut multistatus); + multistatus.children.extend( + items + .into_iter() + .map(dav_response_element) + .map(DavXmlNode::Element), + ); + multistatus +} + +/// Creates the RFC 4918 `supportedlock` property value. +#[must_use] +pub fn dav_supported_lock_element() -> DavXmlElement { + let mut supported = dav_element("supportedlock"); + for scope in ["exclusive", "shared"] { + let mut entry = dav_element("lockentry"); + let mut lockscope = dav_element("lockscope"); + lockscope + .children + .push(DavXmlNode::Element(dav_element(scope))); + entry.children.push(DavXmlNode::Element(lockscope)); + let mut locktype = dav_element("locktype"); + locktype + .children + .push(DavXmlNode::Element(dav_element("write"))); + entry.children.push(DavXmlNode::Element(locktype)); + supported.children.push(DavXmlNode::Element(entry)); + } + supported +} + +/// Creates the RFC 4918 `lockdiscovery` property value. +#[must_use] +pub fn dav_lock_discovery_element(locks: &[DavLockXml]) -> DavXmlElement { + let mut discovery = dav_element("lockdiscovery"); + discovery.children.extend( + locks + .iter() + .map(active_lock_element) + .map(DavXmlNode::Element), + ); + discovery +} + +/// Creates a complete LOCK response `` document. +#[must_use] +pub fn dav_lock_response_element(locks: &[DavLockXml]) -> DavXmlElement { + let mut prop = dav_element("prop"); + declare_dav_namespace(&mut prop); + prop.children + .push(DavXmlNode::Element(dav_lock_discovery_element(locks))); + prop +} + +/// Creates a complete DeltaV version-tree multistatus document. +#[must_use] +pub fn dav_version_multistatus_element(versions: Vec) -> DavXmlElement { + let items = versions + .into_iter() + .map(|version| { + DavMultiStatusItem::properties( + version.href, + vec![DavPropStat { + status: StatusCode::OK.as_u16(), + properties: vec![ + dav_text_element("version-name", version.version_name), + dav_text_element("creator-displayname", version.creator), + dav_text_element("getcontentlength", version.content_length.to_string()), + dav_text_element("getlastmodified", version.last_modified), + ], + }], + ) + }) + .collect(); + dav_multistatus_element(items) +} + +fn active_lock_element(lock: &DavLockXml) -> DavXmlElement { + let mut active = dav_element("activelock"); + let mut lockscope = dav_element("lockscope"); + lockscope + .children + .push(DavXmlNode::Element(dav_element(if lock.shared { + "shared" + } else { + "exclusive" + }))); + active.children.push(DavXmlNode::Element(lockscope)); + + let mut locktype = dav_element("locktype"); + locktype + .children + .push(DavXmlNode::Element(dav_element("write"))); + active.children.push(DavXmlNode::Element(locktype)); + active.children.push(DavXmlNode::Element(dav_text_element( + "depth", + if lock.deep { "Infinity" } else { "0" }, + ))); + if let Some(owner) = &lock.owner { + active.children.push(DavXmlNode::Element(owner.clone())); + } + active.children.push(DavXmlNode::Element(dav_text_element( + "timeout", + lock.timeout.map_or_else( + || "Infinite".to_owned(), + |timeout| format!("Second-{}", timeout.as_secs()), + ), + ))); + + let mut token = dav_element("locktoken"); + token.children.push(DavXmlNode::Element(dav_text_element( + "href", + encode_href(&lock.token), + ))); + active.children.push(DavXmlNode::Element(token)); + + let mut lockroot = dav_element("lockroot"); + lockroot.children.push(DavXmlNode::Element(dav_text_element( + "href", + lock.root_href.clone(), + ))); + active.children.push(DavXmlNode::Element(lockroot)); + active +} + +fn error_condition_element(condition: &DavErrorCondition) -> DavXmlElement { + match condition { + DavErrorCondition::NoExternalEntities => dav_element("no-external-entities"), + DavErrorCondition::LockTokenSubmitted { href } => { + let mut condition = dav_element("lock-token-submitted"); + condition + .children + .push(DavXmlNode::Element(dav_text_element("href", href.clone()))); + condition + } + DavErrorCondition::LockTokenMatchesRequestUri => { + dav_element("lock-token-matches-request-uri") + } + DavErrorCondition::PropfindFiniteDepth => dav_element("propfind-finite-depth"), + } +} + +fn declare_dav_namespace(element: &mut DavXmlElement) { + element + .attributes + .insert("xmlns:D".to_owned(), "DAV:".to_owned()); +} + +fn property_element(name: &DavRequestedProperty, child: Option) -> DavXmlElement { + let prefix = name + .prefix + .as_deref() + .filter(|prefix| !matches!(*prefix, "xml" | "xmlns")) + .unwrap_or_else(|| default_property_prefix(name.namespace.as_deref())); + let tag = if name.namespace.is_some() { + format!("{prefix}:{}", name.name) + } else { + name.name.clone() + }; + let mut element = DavXmlElement::new(&tag); + element.namespace.clone_from(&name.namespace); + if let Some(namespace) = &name.namespace + && (namespace != "DAV:" || prefix != "D") + { + element + .attributes + .insert(format!("xmlns:{prefix}"), namespace.clone()); + } + if let Some(child) = child { + element.children.push(child); + } + element +} + +fn default_property_prefix(namespace: Option<&str>) -> &str { + match namespace { + Some("DAV:") => "D", + Some(_) => "A", + None => "", + } +} diff --git a/crates/aster_forge_webdav/tests/actix.rs b/crates/aster_forge_webdav/tests/actix.rs new file mode 100644 index 0000000..2d67398 --- /dev/null +++ b/crates/aster_forge_webdav/tests/actix.rs @@ -0,0 +1,142 @@ +#![cfg(feature = "actix")] + +use actix_web::http::StatusCode; +use actix_web::{FromRequest, web}; +use aster_forge_webdav::{DavBodyError, DavMethod, DavPrecondition}; +use bytes::Bytes; +use futures::StreamExt; +use std::pin::Pin; + +async fn payload_from_bytes(bytes: Bytes) -> web::Payload { + let (request, mut payload) = actix_web::test::TestRequest::default() + .set_payload(bytes) + .to_http_parts(); + web::Payload::from_request(&request, &mut payload) + .await + .expect("test payload should extract") +} + +async fn payload_from_chunks(chunks: &[&'static [u8]]) -> web::Payload { + let stream = futures::stream::iter( + chunks + .iter() + .map(|chunk| Ok(Bytes::from_static(chunk))) + .collect::>>(), + ); + let stream: Pin< + Box>>, + > = Box::pin(stream); + let mut payload = actix_web::dev::Payload::from(stream); + let request = actix_web::test::TestRequest::default().to_http_request(); + web::Payload::from_request(&request, &mut payload) + .await + .expect("chunked test payload should extract") +} + +#[actix_web::test] +async fn empty_body_policy_accepts_empty_and_rejects_the_first_nonempty_chunk() { + let mut empty = payload_from_bytes(Bytes::new()).await; + aster_forge_webdav::actix::ensure_empty_body(&mut empty) + .await + .expect("empty body should be accepted"); + + let mut nonempty = payload_from_bytes(Bytes::from(vec![b'x'; 2 * 1024 * 1024])).await; + assert_eq!( + aster_forge_webdav::actix::ensure_empty_body(&mut nonempty).await, + Err(DavBodyError::BodyNotAllowed) + ); +} + +#[actix_web::test] +async fn bounded_xml_body_accepts_the_exact_limit_and_rejects_one_byte_over() { + let mut exact = payload_from_bytes(Bytes::from_static(b"1234")).await; + assert_eq!( + aster_forge_webdav::actix::collect_bounded_xml_body(&mut exact, 4) + .await + .expect("exact body limit should be accepted"), + b"1234" + ); + + let mut over = payload_from_bytes(Bytes::from_static(b"12345")).await; + assert_eq!( + aster_forge_webdav::actix::collect_bounded_xml_body(&mut over, 4).await, + Err(DavBodyError::XmlTooLarge) + ); + + let mut cumulative = payload_from_chunks(&[b"12", b"34", b"5"]).await; + assert_eq!( + aster_forge_webdav::actix::collect_bounded_xml_body(&mut cumulative, 4).await, + Err(DavBodyError::XmlTooLarge) + ); + + let mut zero_empty = payload_from_bytes(Bytes::new()).await; + assert_eq!( + aster_forge_webdav::actix::collect_bounded_xml_body(&mut zero_empty, 0).await, + Ok(Vec::new()) + ); + let mut zero_nonempty = payload_from_bytes(Bytes::from_static(b"x")).await; + assert_eq!( + aster_forge_webdav::actix::collect_bounded_xml_body(&mut zero_nonempty, 0).await, + Err(DavBodyError::XmlTooLarge) + ); +} + +#[actix_web::test] +async fn method_body_preparation_collects_xml_rejects_empty_policy_and_preserves_streams() { + let mut xml = payload_from_bytes(Bytes::from_static(b"")).await; + let prepared = + aster_forge_webdav::actix::prepare_request_body(DavMethod::Propfind, &mut xml, 64) + .await + .expect("PROPFIND body"); + assert_eq!(prepared.xml(), b""); + + let mut forbidden = payload_from_bytes(Bytes::from_static(b"x")).await; + assert!(matches!( + aster_forge_webdav::actix::prepare_request_body(DavMethod::Options, &mut forbidden, 64,) + .await, + Err(DavBodyError::BodyNotAllowed) + )); + + let mut stream = payload_from_bytes(Bytes::from_static(b"payload")).await; + let prepared = aster_forge_webdav::actix::prepare_request_body(DavMethod::Put, &mut stream, 64) + .await + .expect("PUT stream policy"); + assert!(prepared.xml().is_empty()); + assert_eq!( + stream.next().await.expect("stream chunk").expect("chunk"), + Bytes::from_static(b"payload") + ); +} + +#[test] +fn header_and_precondition_adapter_preserves_success_and_error_contracts() { + let matching_none_match = actix_web::test::TestRequest::default() + .insert_header(("If-None-Match", "\"etag-a\"")) + .to_http_request(); + assert_eq!( + aster_forge_webdav::actix::evaluate_http_etag_preconditions( + matching_none_match.headers(), + true, + Some("etag-a"), + true, + ) + .expect("safe matching If-None-Match should be a valid precondition"), + DavPrecondition::NotModified + ); + + let mismatching_if_match = actix_web::test::TestRequest::default() + .insert_header(("If-Match", "\"etag-b\"")) + .to_http_request(); + let response = aster_forge_webdav::actix::evaluate_http_etag_preconditions( + mismatching_if_match.headers(), + true, + Some("etag-a"), + false, + ) + .expect_err("mismatching If-Match should fail"); + assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED); + + let converted = aster_forge_webdav::actix::converted_headers(mismatching_if_match.headers()) + .expect("valid Actix headers should convert"); + assert_eq!(converted.get("If-Match").expect("If-Match"), "\"etag-b\""); +} diff --git a/crates/aster_forge_webdav/tests/backend.rs b/crates/aster_forge_webdav/tests/backend.rs new file mode 100644 index 0000000..f00b163 --- /dev/null +++ b/crates/aster_forge_webdav/tests/backend.rs @@ -0,0 +1,38 @@ +use aster_forge_webdav::{DavBackendError, DavBackendErrorKind, FsError, OpenOptions}; + +#[test] +fn filesystem_errors_map_exhaustively_to_protocol_backend_categories() { + let cases = [ + (FsError::NotFound, DavBackendErrorKind::NotFound), + (FsError::Forbidden, DavBackendErrorKind::Forbidden), + (FsError::GeneralFailure, DavBackendErrorKind::Internal), + (FsError::Exists, DavBackendErrorKind::AlreadyExists), + ( + FsError::InsufficientStorage, + DavBackendErrorKind::InsufficientStorage, + ), + (FsError::TooLarge, DavBackendErrorKind::PayloadTooLarge), + (FsError::BadRequest, DavBackendErrorKind::InvalidInput), + ]; + for (error, expected) in cases { + assert_eq!(DavBackendError::from(error).kind, expected); + } +} + +#[test] +fn open_modes_are_transport_neutral_values() { + assert_eq!( + OpenOptions::read(), + OpenOptions { + read: true, + ..OpenOptions::default() + } + ); + assert_eq!( + OpenOptions::write(), + OpenOptions { + write: true, + ..OpenOptions::default() + } + ); +} diff --git a/crates/aster_forge_webdav/tests/deltav.rs b/crates/aster_forge_webdav/tests/deltav.rs new file mode 100644 index 0000000..dcf1244 --- /dev/null +++ b/crates/aster_forge_webdav/tests/deltav.rs @@ -0,0 +1,115 @@ +use aster_forge_webdav::{ + DavResourceKind, DavResponse, DavResponseBody, DavVersionTreeReportError, DavVersionXml, + DavXmlError, validate_version_tree_report, version_control_response, + version_tree_non_file_response, version_tree_report_error_response, version_tree_response, +}; +use http::StatusCode; + +fn body_text(response: &DavResponse) -> String { + let DavResponseBody::Bytes(body) = &response.body else { + panic!("response should contain bytes"); + }; + String::from_utf8(body.to_vec()).expect("UTF-8 response") +} + +#[test] +fn report_selector_accepts_only_dav_version_tree() { + validate_version_tree_report(br#""#).expect("valid report"); + validate_version_tree_report(br#""#) + .expect("prefix is lexical only"); + + assert_eq!( + validate_version_tree_report(br#""#), + Err(DavVersionTreeReportError::Unsupported { + name: "expand-property".to_owned(), + }) + ); + assert_eq!( + validate_version_tree_report(br#""#), + Err(DavVersionTreeReportError::Unsupported { + name: "version-tree".to_owned(), + }) + ); +} + +#[test] +fn report_selector_preserves_xml_failure_categories() { + assert_eq!( + validate_version_tree_report(b"]>"#, + ), + Err(DavVersionTreeReportError::Xml( + DavXmlError::ExternalEntity + )) + ); +} + +#[test] +fn report_errors_select_plain_or_dav_xml_responses() { + let invalid = + version_tree_report_error_response(&DavVersionTreeReportError::Xml(DavXmlError::Malformed)) + .expect("invalid response"); + assert_eq!(invalid.status, StatusCode::BAD_REQUEST); + assert_eq!(invalid.headers.get("Cache-Control").unwrap(), "no-store"); + assert_eq!(body_text(&invalid), "Invalid XML body"); + + let unsupported = version_tree_report_error_response(&DavVersionTreeReportError::Unsupported { + name: "expand-property".to_owned(), + }) + .expect("unsupported response"); + assert_eq!(unsupported.status, StatusCode::NOT_IMPLEMENTED); + assert!(body_text(&unsupported).contains("expand-property")); + + let external = version_tree_report_error_response(&DavVersionTreeReportError::Xml( + DavXmlError::ExternalEntity, + )) + .expect("external entity response"); + assert_eq!(external.status, StatusCode::FORBIDDEN); + assert_eq!( + external.headers.get("Content-Type").unwrap(), + "application/xml; charset=utf-8" + ); + assert!(body_text(&external).contains("no-external-entities")); + + let too_large = + version_tree_report_error_response(&DavVersionTreeReportError::Xml(DavXmlError::TooLarge)) + .expect("too large response"); + assert_eq!(too_large.status, StatusCode::PAYLOAD_TOO_LARGE); +} + +#[test] +fn version_tree_response_composes_multistatus_properties() { + let response = version_tree_response(vec![DavVersionXml { + href: "/webdav/file.txt?v=1".to_owned(), + version_name: "V1".to_owned(), + creator: "alice".to_owned(), + content_length: 42, + last_modified: "Sat, 25 Jul 2026 00:00:00 GMT".to_owned(), + }]) + .expect("version response"); + assert_eq!(response.status, StatusCode::MULTI_STATUS); + assert!(response.headers.get("Cache-Control").is_none()); + let xml = body_text(&response); + assert!(xml.contains("version-name"), "{xml}"); + assert!(xml.contains("V1"), "{xml}"); + assert!(xml.contains("42"), "{xml}"); +} + +#[test] +fn file_only_and_version_control_responses_select_protocol_status() { + let report = version_tree_non_file_response(); + assert_eq!(report.status, StatusCode::CONFLICT); + assert_eq!(report.headers.get("Cache-Control").unwrap(), "no-store"); + + let file = version_control_response(DavResourceKind::File); + assert_eq!(file.status, StatusCode::OK); + assert!(file.headers.get("Cache-Control").is_none()); + + let collection = version_control_response(DavResourceKind::Collection); + assert_eq!(collection.status, StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(collection.headers.get("Cache-Control").unwrap(), "no-store"); +} diff --git a/crates/aster_forge_webdav/tests/event.rs b/crates/aster_forge_webdav/tests/event.rs new file mode 100644 index 0000000..f61028f --- /dev/null +++ b/crates/aster_forge_webdav/tests/event.rs @@ -0,0 +1,88 @@ +use std::time::Duration; + +use aster_forge_webdav::{ + DavBackendErrorKind, DavEvent, DavEventOutcome, DavMethod, DavOperation, DavPath, + DavRequestHead, DavRequestOrigin, Destination, IfHeader, IfResourceGroup, IfStateCondition, + IfStateList, +}; + +#[test] +fn event_outcome_classifies_the_complete_http_status_boundary() { + for status in [100, 200, 207, 304, 399] { + assert_eq!( + DavEventOutcome::from_status(status, Some(DavBackendErrorKind::Internal)), + DavEventOutcome::Succeeded { status } + ); + } + + for status in [400, 404, 423, 500, 599] { + assert_eq!( + DavEventOutcome::from_status(status, Some(DavBackendErrorKind::Internal)), + DavEventOutcome::Failed { + status, + backend_error: Some(DavBackendErrorKind::Internal), + } + ); + } +} + +#[test] +fn event_outcome_exposes_its_transport_neutral_status() { + assert_eq!(DavEventOutcome::from_status(207, None).status(), 207); + assert_eq!(DavEventOutcome::from_status(423, None).status(), 423); +} + +#[test] +fn completed_event_copies_only_protocol_routing_data() { + let request_head = DavRequestHead { + method: DavMethod::Copy, + target: DavPath::new("/source.txt").expect("source path"), + origin: DavRequestOrigin { + scheme: "https".to_owned(), + host: "dav.example".to_owned(), + }, + depth: None, + overwrite: Some(true), + destination: Some(Destination { + path: DavPath::new("/destination.txt").expect("destination path"), + relative: "/destination.txt".to_owned(), + }), + if_header: Some(IfHeader { + groups: vec![IfResourceGroup { + tagged_path: None, + lists: vec![IfStateList { + conditions: vec![IfStateCondition::Token { + value: "urn:uuid:sensitive-lock-token".to_owned(), + negated: false, + }], + }], + }], + }), + }; + + let event = DavEvent::completed( + &request_head, + 423, + Duration::from_millis(12), + Some(DavBackendErrorKind::Conflict), + ); + + assert_eq!(event.request_id, None); + assert_eq!(event.operation, DavOperation::Copy); + assert_eq!(event.source.as_str(), "/source.txt"); + assert_eq!( + event.destination.as_ref().map(|path| path.as_str()), + Some("/destination.txt") + ); + assert_eq!( + event.outcome, + DavEventOutcome::Failed { + status: 423, + backend_error: Some(DavBackendErrorKind::Conflict), + } + ); + assert_eq!(event.elapsed, Duration::from_millis(12)); + let debug = format!("{event:?}"); + assert!(debug.contains("source.txt")); + assert!(!debug.contains("sensitive-lock-token")); +} diff --git a/crates/aster_forge_webdav/tests/guard.rs b/crates/aster_forge_webdav/tests/guard.rs new file mode 100644 index 0000000..6ff11a9 --- /dev/null +++ b/crates/aster_forge_webdav/tests/guard.rs @@ -0,0 +1,603 @@ +use std::collections::{HashMap, HashSet}; +use std::io::SeekFrom; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +use aster_forge_webdav::{ + DavBackendErrorKind, DavDirEntry, DavFile, DavFileSystem, DavIfEvaluationError, DavLock, + DavLockError, DavLockPreflightError, DavLockSystem, DavMetaData, DavPath, DavResponseBody, + FsError, FsFuture, FsStream, LsFuture, OpenOptions, ReadDirMeta, + enforce_if_header_with_backends, enforce_parent_collection, enforce_parent_unlocked, + enforce_unlocked, ensure_lock_target_exists, parse_if_header, unsubmitted_lock_conflicts, +}; +use bytes::{Buf, Bytes}; +use http::StatusCode; +use http::header::{HeaderMap, HeaderValue}; + +#[derive(Clone)] +struct TestMeta { + etag: Option, + is_dir: bool, +} + +impl DavMetaData for TestMeta { + fn len(&self) -> u64 { + 0 + } + + fn modified(&self) -> Result { + Ok(SystemTime::UNIX_EPOCH) + } + + fn is_dir(&self) -> bool { + self.is_dir + } + + fn etag(&self) -> Option { + self.etag.clone() + } + + fn created(&self) -> Result { + Ok(SystemTime::UNIX_EPOCH) + } +} + +#[derive(Default)] +struct TestFileSystem { + etags: HashMap, + directories: HashSet, + failures: HashMap, + open_failure: Option, + flush_failure: Option, + open_calls: Mutex>, + flush_calls: Arc, +} + +struct TestFile { + flush_failure: Option, + flush_calls: Arc, +} + +impl DavFile for TestFile { + fn metadata<'a>(&'a mut self) -> FsFuture<'a, Box> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } + + fn read_bytes(&mut self, _count: usize) -> FsFuture<'_, Bytes> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } + + fn write_bytes(&mut self, _buf: Bytes) -> FsFuture<'_, ()> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } + + fn write_buf(&mut self, _buf: Box) -> FsFuture<'_, ()> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } + + fn seek(&mut self, _pos: SeekFrom) -> FsFuture<'_, u64> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } + + fn flush(&mut self) -> FsFuture<'_, ()> { + self.flush_calls.fetch_add(1, Ordering::SeqCst); + let failure = self.flush_failure; + Box::pin(async move { failure.map_or(Ok(()), Err) }) + } +} + +impl DavFileSystem for TestFileSystem { + fn open<'a>( + &'a self, + path: &'a DavPath, + options: OpenOptions, + ) -> FsFuture<'a, Box> { + self.open_calls + .lock() + .expect("open call log should not be poisoned") + .push((path.as_str().to_owned(), options)); + let failure = self.open_failure; + let file = TestFile { + flush_failure: self.flush_failure, + flush_calls: Arc::clone(&self.flush_calls), + }; + Box::pin(async move { + match failure { + Some(error) => Err(error), + None => Ok(Box::new(file) as Box), + } + }) + } + + fn read_dir<'a>( + &'a self, + _path: &'a DavPath, + _meta: ReadDirMeta, + ) -> FsFuture<'a, FsStream>> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } + + fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box> { + Box::pin(async move { + if let Some(error) = self.failures.get(path.as_str()) { + return Err(*error); + } + let is_dir = self.directories.contains(path.as_str()); + let etag = self.etags.get(path.as_str()).cloned(); + if !is_dir && etag.is_none() { + return Err(FsError::NotFound); + } + Ok(Box::new(TestMeta { etag, is_dir }) as Box) + }) + } + + fn create_dir<'a>(&'a self, _path: &'a DavPath) -> FsFuture<'a, ()> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } + + fn remove_dir<'a>(&'a self, _path: &'a DavPath) -> FsFuture<'a, ()> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } + + fn remove_file<'a>(&'a self, _path: &'a DavPath) -> FsFuture<'a, ()> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } + + fn rename<'a>(&'a self, _from: &'a DavPath, _to: &'a DavPath) -> FsFuture<'a, ()> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } + + fn copy<'a>(&'a self, _from: &'a DavPath, _to: &'a DavPath) -> FsFuture<'a, ()> { + Box::pin(async { Err(FsError::GeneralFailure) }) + } +} + +#[derive(Default)] +struct TestLockSystem { + discovered: Vec, + conflicts: Vec, + conflict_calls: Mutex>, +} + +impl DavLockSystem for TestLockSystem { + fn prepare_lock(&self, _path: &DavPath) -> LsFuture<'_, Result<(), DavLockPreflightError>> { + Box::pin(async { Ok(()) }) + } + + fn lock( + &self, + _path: &DavPath, + _principal: Option<&str>, + _owner: Option<&aster_forge_webdav::DavXmlElement>, + _timeout: Option, + _shared: bool, + _deep: bool, + ) -> LsFuture<'_, Result> { + Box::pin(async { Err(DavLockError::Backend) }) + } + + fn unlock(&self, _path: &DavPath, _token: &str) -> LsFuture<'_, Result<(), DavLockError>> { + Box::pin(async { Err(DavLockError::TokenMismatch) }) + } + + fn refresh( + &self, + _path: &DavPath, + _token: &str, + _timeout: Option, + ) -> LsFuture<'_, Result> { + Box::pin(async { Err(DavLockError::TokenMismatch) }) + } + + fn check( + &self, + _path: &DavPath, + _principal: Option<&str>, + _ignore_principal: bool, + _deep: bool, + _submitted_tokens: &[String], + ) -> LsFuture<'_, Result<(), DavLock>> { + Box::pin(async { Ok(()) }) + } + + fn discover(&self, path: &DavPath) -> LsFuture<'_, Vec> { + let discovered = self + .discovered + .iter() + .filter(|lock| lock.path.as_str() == path.as_str()) + .cloned() + .collect(); + Box::pin(async move { discovered }) + } + + fn conflicting_locks(&self, path: &DavPath, deep: bool) -> LsFuture<'_, Vec> { + self.conflict_calls + .lock() + .expect("conflict call log should not be poisoned") + .push((path.as_str().to_owned(), deep)); + let conflicts = self.conflicts.clone(); + Box::pin(async move { conflicts }) + } + + fn delete(&self, _path: &DavPath) -> LsFuture<'_, Result<(), DavLockError>> { + Box::pin(async { Ok(()) }) + } +} + +fn lock(path: &str, token: &str) -> DavLock { + DavLock { + token: token.to_owned(), + path: Box::new(DavPath::new(path).expect("test lock path should be valid")), + principal: None, + owner: None, + timeout_at: None, + timeout: Some(Duration::from_secs(60)), + shared: false, + deep: true, + } +} + +fn if_header(value: &'static str) -> aster_forge_webdav::IfHeader { + let mut headers = HeaderMap::new(); + headers.insert("If", HeaderValue::from_static(value)); + parse_if_header(&headers) + .expect("test If header should parse") + .expect("test If header should exist") +} + +#[test] +fn backend_if_resolver_combines_filesystem_etag_and_lock_tokens() { + futures::executor::block_on(async { + let path = DavPath::new("/a.txt").expect("test path"); + let filesystem = TestFileSystem { + etags: HashMap::from([("/a.txt".to_owned(), "etag-a".to_owned())]), + ..TestFileSystem::default() + }; + let lock_system = TestLockSystem { + discovered: vec![lock("/a.txt", "urn:uuid:lock-a")], + ..TestLockSystem::default() + }; + let header = if_header("( [\"etag-a\"])"); + + enforce_if_header_with_backends( + Some(&header), + &filesystem, + &lock_system, + &path, + "/webdav", + "https", + "dav.example", + ) + .await + .expect("matching filesystem and lock state should satisfy If"); + }); +} + +#[test] +fn backend_if_resolver_preserves_failures_and_treats_missing_as_empty_state() { + futures::executor::block_on(async { + let path = DavPath::new("/a.txt").expect("test path"); + let failing = TestFileSystem { + failures: HashMap::from([("/a.txt".to_owned(), FsError::Forbidden)]), + ..TestFileSystem::default() + }; + let error = enforce_if_header_with_backends( + Some(&if_header("(Not [\"etag-a\"])")), + &failing, + &TestLockSystem::default(), + &path, + "/webdav", + "https", + "dav.example", + ) + .await + .expect_err("filesystem failure should remain a backend failure"); + assert!(matches!( + error, + DavIfEvaluationError::Backend(error) if error.kind == DavBackendErrorKind::Forbidden + )); + + enforce_if_header_with_backends( + Some(&if_header("(Not [\"etag-a\"])")), + &TestFileSystem::default(), + &TestLockSystem::default(), + &path, + "/webdav", + "https", + "dav.example", + ) + .await + .expect("a missing resource should contribute empty state"); + }); +} + +#[test] +fn lock_guard_requires_a_token_scoped_to_the_conflicting_lock_root() { + futures::executor::block_on(async { + let target = DavPath::new("/locked/child.txt").expect("test path"); + let lock_system = TestLockSystem { + conflicts: vec![lock("/locked/", "urn:uuid:root-lock")], + ..TestLockSystem::default() + }; + + let response = enforce_unlocked( + &lock_system, + &target, + true, + "/webdav", + Some(&if_header(" ()")), + "https", + "dav.example", + ) + .await + .expect_err("a token tagged for another resource must not unlock this target"); + assert_eq!(response.status, StatusCode::LOCKED); + assert!(matches!(response.body, DavResponseBody::Bytes(_))); + + let accepted = enforce_unlocked( + &lock_system, + &target, + true, + "/webdav", + Some(&if_header(" ()")), + "https", + "dav.example", + ) + .await; + assert!( + accepted.is_ok(), + "a token tagged for the lock root should unlock the target" + ); + + assert_eq!( + lock_system + .conflict_calls + .lock() + .expect("conflict call log should not be poisoned") + .as_slice(), + [ + ("/locked/child.txt".to_owned(), true), + ("/locked/child.txt".to_owned(), true), + ] + ); + }); +} + +#[test] +fn parent_lock_guard_checks_the_canonical_parent_and_skips_mount_root() { + futures::executor::block_on(async { + let target = DavPath::new("/folder/new.txt").expect("test path"); + let lock_system = TestLockSystem { + conflicts: vec![lock("/folder/", "urn:uuid:parent-lock")], + ..TestLockSystem::default() + }; + + let accepted = enforce_parent_unlocked( + &lock_system, + &target, + "/webdav", + Some(&if_header(" ()")), + "https", + "dav.example", + ) + .await; + assert!( + accepted.is_ok(), + "the parent-scoped token should allow the mutation" + ); + assert_eq!( + lock_system + .conflict_calls + .lock() + .expect("conflict call log should not be poisoned") + .as_slice(), + [("/folder/".to_owned(), false)] + ); + + let root_locks = TestLockSystem::default(); + let root_result = enforce_parent_unlocked( + &root_locks, + &DavPath::new("/").expect("mount root"), + "/webdav", + None, + "https", + "dav.example", + ) + .await; + assert!(root_result.is_ok(), "the mount root has no parent to check"); + assert!( + root_locks + .conflict_calls + .lock() + .expect("conflict call log should not be poisoned") + .is_empty() + ); + }); +} + +#[test] +fn conflict_filter_removes_only_tokens_submitted_for_each_lock_root() { + futures::executor::block_on(async { + let target = DavPath::new("/tree/").expect("test path"); + let lock_system = TestLockSystem { + conflicts: vec![ + lock("/tree/a.txt", "urn:uuid:a"), + lock("/tree/b.txt", "urn:uuid:b"), + lock("/tree/c.txt", "urn:uuid:c"), + ], + ..TestLockSystem::default() + }; + let header = if_header( + " () () ()", + ); + + let conflicts = unsubmitted_lock_conflicts( + &lock_system, + &target, + true, + "/webdav", + Some(&header), + "https", + "dav.example", + ) + .await; + assert_eq!( + conflicts + .iter() + .map(|lock| lock.path.as_str()) + .collect::>(), + ["/tree/b.txt", "/tree/c.txt"] + ); + }); +} + +#[test] +fn parent_collection_guard_covers_root_collection_missing_and_backend_boundaries() { + futures::executor::block_on(async { + let filesystem = TestFileSystem { + directories: HashSet::from(["/folder/".to_owned()]), + etags: HashMap::from([("/file/".to_owned(), "etag-file".to_owned())]), + failures: HashMap::from([("/private/".to_owned(), FsError::Forbidden)]), + ..TestFileSystem::default() + }; + + let root_error = + enforce_parent_collection(&filesystem, &DavPath::new("/").expect("mount root")) + .await + .expect_err("the mount root cannot be created below itself"); + assert_eq!(root_error.status, StatusCode::METHOD_NOT_ALLOWED); + + assert!( + enforce_parent_collection(&filesystem, &DavPath::new("/new.txt").expect("root child")) + .await + .is_ok(), + "the implicit mount root is a valid parent" + ); + assert!( + enforce_parent_collection( + &filesystem, + &DavPath::new("/folder/new.txt").expect("nested child"), + ) + .await + .is_ok(), + "an existing collection is a valid parent" + ); + + for target in ["/file/new.txt", "/missing/new.txt"] { + let response = enforce_parent_collection( + &filesystem, + &DavPath::new(target).expect("mutation target"), + ) + .await + .expect_err("a file or missing resource cannot be a collection parent"); + assert_eq!(response.status, StatusCode::CONFLICT); + } + + let response = enforce_parent_collection( + &filesystem, + &DavPath::new("/private/new.txt").expect("forbidden parent"), + ) + .await + .expect_err("backend errors must retain their protocol classification"); + assert_eq!(response.status, StatusCode::FORBIDDEN); + }); +} + +#[test] +fn lock_target_guard_creates_only_missing_files_and_preserves_failures() { + futures::executor::block_on(async { + let existing = TestFileSystem { + etags: HashMap::from([("/existing.txt".to_owned(), "etag".to_owned())]), + ..TestFileSystem::default() + }; + assert!( + ensure_lock_target_exists( + &existing, + &DavPath::new("/existing.txt").expect("existing file"), + ) + .await + .expect("existing target lookup") + ); + assert!( + existing + .open_calls + .lock() + .expect("open call log should not be poisoned") + .is_empty() + ); + + let missing = TestFileSystem::default(); + assert!( + !ensure_lock_target_exists(&missing, &DavPath::new("/new.txt").expect("missing file"),) + .await + .expect("a missing file should become a lock-null resource") + ); + assert_eq!(missing.flush_calls.load(Ordering::SeqCst), 1); + assert_eq!( + missing + .open_calls + .lock() + .expect("open call log should not be poisoned") + .as_slice(), + [( + "/new.txt".to_owned(), + OpenOptions { + write: true, + create: true, + truncate: true, + size: Some(0), + ..OpenOptions::default() + } + )] + ); + + let missing_collection = TestFileSystem::default(); + assert_eq!( + ensure_lock_target_exists( + &missing_collection, + &DavPath::new("/new/").expect("missing collection"), + ) + .await, + Err(FsError::NotFound) + ); + assert!( + missing_collection + .open_calls + .lock() + .expect("open call log should not be poisoned") + .is_empty() + ); + + for (filesystem, expected) in [ + ( + TestFileSystem { + failures: HashMap::from([("/failed.txt".to_owned(), FsError::Forbidden)]), + ..TestFileSystem::default() + }, + FsError::Forbidden, + ), + ( + TestFileSystem { + open_failure: Some(FsError::InsufficientStorage), + ..TestFileSystem::default() + }, + FsError::InsufficientStorage, + ), + ( + TestFileSystem { + flush_failure: Some(FsError::GeneralFailure), + ..TestFileSystem::default() + }, + FsError::GeneralFailure, + ), + ] { + let result = ensure_lock_target_exists( + &filesystem, + &DavPath::new("/failed.txt").expect("failed target"), + ) + .await; + assert_eq!(result, Err(expected)); + } + }); +} diff --git a/crates/aster_forge_webdav/tests/lock.rs b/crates/aster_forge_webdav/tests/lock.rs new file mode 100644 index 0000000..f0e6ae5 --- /dev/null +++ b/crates/aster_forge_webdav/tests/lock.rs @@ -0,0 +1,191 @@ +use std::time::Duration; + +use aster_forge_webdav::{ + DavLock, DavLockPlan, DavLockPlanError, DavMethod, DavPath, DavProtocolErrorKind, + DavRequestHead, DavRequestOrigin, DavResponseBody, Depth, IfHeader, + lock_acquire_success_response, lock_conflict_response, lock_discovery_element, + lock_limit_response, lock_refresh_success_response, lock_xml_error_response, parse_if_header, + plan_lock_request, unlock_success_response, unlock_token_mismatch_response, +}; +use http::StatusCode; +use http::header::{HeaderMap, HeaderValue}; + +fn headers(name: &'static str, value: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(name, HeaderValue::from_static(value)); + headers +} + +fn request_head(depth: Depth, if_header: Option) -> DavRequestHead { + DavRequestHead { + method: DavMethod::Lock, + target: DavPath::new("/a.txt").expect("path"), + origin: DavRequestOrigin { + scheme: "https".to_owned(), + host: "dav.example".to_owned(), + }, + depth: Some(depth), + overwrite: None, + destination: None, + if_header, + } +} + +#[test] +fn lock_plan_selects_acquire_with_parsed_body_depth_and_timeout() { + let body = br#"owner"#; + let plan = plan_lock_request( + &headers("Timeout", "Second-60"), + body, + &request_head(Depth::Infinity, None), + "/webdav", + Duration::from_secs(600), + ) + .expect("acquire plan"); + assert!(matches!( + plan, + DavLockPlan::Acquire { + timeout, + shared: true, + deep: true, + owner: Some(_), + } if timeout == Duration::from_secs(60) + )); +} + +#[test] +fn empty_lock_body_requires_exactly_one_scoped_refresh_token() { + let if_header = parse_if_header(&headers( + "If", + " () ()", + )) + .expect("If grammar") + .expect("If header"); + let plan = plan_lock_request( + &HeaderMap::new(), + &[], + &request_head(Depth::Zero, Some(if_header)), + "/webdav", + Duration::from_secs(600), + ) + .expect("one path-scoped token should refresh"); + assert!(matches!(plan, DavLockPlan::Refresh { token, .. } if token == "urn:uuid:a")); + + let error = plan_lock_request( + &HeaderMap::new(), + &[], + &request_head(Depth::Zero, None), + "/webdav", + Duration::from_secs(600), + ) + .expect_err("missing refresh token should fail"); + assert!(matches!( + error, + DavLockPlanError::Protocol(error) if error.kind() == DavProtocolErrorKind::BadRequest + )); +} + +#[test] +fn lock_success_responses_own_status_xml_and_lock_token_header_contract() { + let lock = DavLock { + token: "urn:uuid:lock".to_string(), + path: Box::new(DavPath::new("/a.txt").expect("path")), + principal: None, + owner: None, + timeout_at: None, + timeout: Some(Duration::from_secs(60)), + shared: false, + deep: false, + }; + let response = lock_acquire_success_response(&lock, "/webdav", false).expect("LOCK response"); + assert_eq!(response.status, StatusCode::CREATED); + assert_eq!( + response.headers.get("Lock-Token").unwrap(), + "" + ); + assert_eq!( + response.headers.get("Content-Type").unwrap(), + "application/xml; charset=utf-8" + ); + assert!(matches!(response.body, DavResponseBody::Bytes(_))); + + let refreshed = lock_refresh_success_response(&lock, "/webdav").expect("refresh response"); + assert_eq!(refreshed.status, StatusCode::OK); + assert!(refreshed.headers.get("Lock-Token").is_none()); + + let existing = + lock_acquire_success_response(&lock, "/webdav", true).expect("existing resource response"); + assert_eq!(existing.status, StatusCode::OK); + assert_eq!( + existing.headers.get("Lock-Token").unwrap(), + "" + ); + + let discovery = lock_discovery_element(&[lock], "/webdav") + .to_bytes() + .expect("lockdiscovery XML"); + let discovery = String::from_utf8(discovery).expect("UTF-8 lockdiscovery XML"); + assert!(discovery.contains("/webdav/a.txt")); + assert!(discovery.contains("urn:uuid:lock")); +} + +#[test] +fn lock_and_unlock_failures_own_status_dav_error_and_cache_contract() { + let path = DavPath::new("/folder/a b.txt").expect("path"); + let conflict = lock_conflict_response("/webdav", &path).expect("conflict response"); + assert_eq!(conflict.status, StatusCode::LOCKED); + assert_eq!(conflict.headers.get("Cache-Control").unwrap(), "no-store"); + let DavResponseBody::Bytes(body) = conflict.body else { + panic!("conflict should have XML body"); + }; + let xml = String::from_utf8(body.to_vec()).expect("UTF-8 XML"); + assert!(xml.contains("lock-token-submitted"), "{xml}"); + assert!(xml.contains("a%20b.txt"), "{xml}"); + + let mismatch = unlock_token_mismatch_response().expect("mismatch response"); + assert_eq!(mismatch.status, StatusCode::CONFLICT); + assert_eq!(mismatch.headers.get("Cache-Control").unwrap(), "no-store"); + let DavResponseBody::Bytes(body) = mismatch.body else { + panic!("mismatch should have XML body"); + }; + let xml = String::from_utf8(body.to_vec()).expect("UTF-8 XML"); + assert!(xml.contains("lock-token-matches-request-uri"), "{xml}"); +} + +#[test] +fn unlock_success_and_lock_limit_are_explicitly_not_cacheable() { + let success = unlock_success_response(); + assert_eq!(success.status, StatusCode::NO_CONTENT); + assert_eq!(success.headers.get("Cache-Control").unwrap(), "no-store"); + + let limit = lock_limit_response(); + assert_eq!(limit.status, StatusCode::INSUFFICIENT_STORAGE); + assert_eq!(limit.headers.get("Cache-Control").unwrap(), "no-store"); + assert_eq!( + limit.headers.get("Content-Type").unwrap(), + "text/plain; charset=utf-8" + ); +} + +#[test] +fn lock_xml_errors_are_mapped_by_the_protocol_layer() { + let invalid = lock_xml_error_response(aster_forge_webdav::DavXmlError::InvalidGrammar) + .expect("invalid LOCK response"); + assert_eq!(invalid.status, StatusCode::BAD_REQUEST); + let DavResponseBody::Bytes(body) = invalid.body else { + panic!("invalid LOCK should have text body"); + }; + assert_eq!(body.as_ref(), b"Invalid LOCK body"); + + let external = lock_xml_error_response(aster_forge_webdav::DavXmlError::ExternalEntity) + .expect("external entity response"); + assert_eq!(external.status, StatusCode::FORBIDDEN); + let DavResponseBody::Bytes(body) = external.body else { + panic!("external entity should have XML body"); + }; + assert!( + String::from_utf8(body.to_vec()) + .unwrap() + .contains("no-external-entities") + ); +} diff --git a/crates/aster_forge_webdav/tests/property.rs b/crates/aster_forge_webdav/tests/property.rs new file mode 100644 index 0000000..66f3510 --- /dev/null +++ b/crates/aster_forge_webdav/tests/property.rs @@ -0,0 +1,184 @@ +use aster_forge_webdav::{ + DavPropfindRequest, DavRequestedProperty, DavResponseBody, DavXmlError, build_propfind_item, + build_proppatch_item, dav_property_name_element, dav_property_text_element, + format_creation_date, plan_atomic_proppatch, property_multistatus_response, + propfind_finite_depth_response, propfind_xml_error_response, proppatch_xml_error_response, +}; +use http::StatusCode; +use std::time::{Duration, UNIX_EPOCH}; + +#[test] +fn creation_date_uses_rfc3339_utc_format() { + assert_eq!( + format_creation_date(UNIX_EPOCH + Duration::from_secs(784_111_777)), + "1994-11-06T08:49:37+00:00" + ); +} + +#[test] +fn atomic_proppatch_plan_selects_success_forbidden_and_failed_dependency() { + let success = plan_atomic_proppatch([false, false]); + assert!(success.apply); + assert_eq!(success.statuses, [StatusCode::OK, StatusCode::OK]); + + let rejected = plan_atomic_proppatch([false, true, false]); + assert!(!rejected.apply); + assert_eq!( + rejected.statuses, + [ + StatusCode::FAILED_DEPENDENCY, + StatusCode::FORBIDDEN, + StatusCode::FAILED_DEPENDENCY, + ] + ); + + let empty = plan_atomic_proppatch([]); + assert!(empty.apply); + assert!(empty.statuses.is_empty()); +} + +fn property(name: &str, namespace: Option<&str>, prefix: Option<&str>) -> DavRequestedProperty { + DavRequestedProperty { + name: name.to_string(), + namespace: namespace.map(str::to_string), + prefix: prefix.map(str::to_string), + } +} + +fn resolve( + requested: &DavRequestedProperty, +) -> Result, ()> { + Ok(match requested.name.as_str() { + "displayname" => Some(dav_property_text_element(requested, "report.txt")), + "color" => Some(dav_property_text_element(requested, "blue")), + _ => None, + }) +} + +#[test] +fn named_properties_are_grouped_into_ordered_200_and_404_propstats() { + let request = DavPropfindRequest::Prop(vec![ + property("missing", Some("DAV:"), Some("D")), + property("displayname", Some("DAV:"), Some("x")), + property("color", Some("urn:test"), Some("t")), + ]); + let item = + build_propfind_item("/dav/a".to_string(), &request, &[], resolve).expect("property item"); + + assert_eq!(item.propstats.len(), 2); + assert_eq!(item.propstats[0].status, 200); + assert_eq!(item.propstats[0].properties.len(), 2); + assert_eq!(item.propstats[1].status, 404); + assert_eq!(item.propstats[1].properties.len(), 1); +} + +#[test] +fn allprop_deduplicates_include_by_expanded_name_and_reports_missing_include() { + let available = vec![ + property("displayname", Some("DAV:"), Some("D")), + property("color", Some("urn:test"), Some("stored")), + ]; + let request = DavPropfindRequest::AllProp { + include: vec![ + property("displayname", Some("DAV:"), Some("other")), + property("extra", Some("urn:test"), Some("t")), + ], + }; + let item = build_propfind_item("/dav/a".to_string(), &request, &available, resolve) + .expect("allprop item"); + + assert_eq!(item.propstats.len(), 2); + assert_eq!(item.propstats[0].status, 200); + assert_eq!(item.propstats[0].properties.len(), 2); + assert_eq!(item.propstats[1].status, 404); + assert_eq!(item.propstats[1].properties.len(), 1); +} + +#[test] +fn propname_uses_catalog_names_without_resolving_values() { + let available = vec![ + property("displayname", Some("DAV:"), Some("D")), + property("color", Some("urn:test"), Some("stored")), + ]; + let mut calls = 0; + let item = build_propfind_item( + "/dav/a".to_string(), + &DavPropfindRequest::PropName, + &available, + |_| { + calls += 1; + Ok::<_, ()>(None) + }, + ) + .expect("propname item"); + + assert_eq!(calls, 0); + assert_eq!(item.propstats.len(), 1); + assert_eq!(item.propstats[0].status, 200); + assert_eq!(item.propstats[0].properties.len(), 2); +} + +#[test] +fn proppatch_groups_outcomes_by_numeric_status_and_preserves_property_order() { + let first = property("first", Some("urn:test"), Some("t")); + let second = property("second", Some("urn:test"), Some("t")); + let failed = property("failed", Some("urn:test"), Some("t")); + let item = build_proppatch_item( + "/dav/a".to_owned(), + [ + (424, dav_property_name_element(&failed)), + (200, dav_property_name_element(&first)), + (200, dav_property_name_element(&second)), + ], + ); + + assert_eq!(item.propstats.len(), 2); + assert_eq!(item.propstats[0].status, 200); + assert_eq!(item.propstats[0].properties[0].name, "first"); + assert_eq!(item.propstats[0].properties[1].name, "second"); + assert_eq!(item.propstats[1].status, 424); + assert_eq!(item.propstats[1].properties[0].name, "failed"); +} + +#[test] +fn property_response_helpers_own_multistatus_depth_and_xml_error_contracts() { + let item = build_proppatch_item( + "/dav/a".to_owned(), + [( + 200, + dav_property_name_element(&property("color", Some("urn:test"), Some("t"))), + )], + ); + let multistatus = property_multistatus_response(vec![item]).expect("multistatus"); + assert_eq!(multistatus.status, StatusCode::MULTI_STATUS); + assert!(multistatus.headers.get("Cache-Control").is_none()); + + let finite = propfind_finite_depth_response().expect("finite depth response"); + assert_eq!(finite.status, StatusCode::FORBIDDEN); + assert_eq!(finite.headers.get("Cache-Control").unwrap(), "no-store"); + let DavResponseBody::Bytes(body) = finite.body else { + panic!("finite depth should have XML body"); + }; + assert!( + String::from_utf8(body.to_vec()) + .unwrap() + .contains("propfind-finite-depth") + ); + + let propfind = propfind_xml_error_response(DavXmlError::InvalidGrammar).unwrap(); + assert_eq!(propfind.status, StatusCode::BAD_REQUEST); + let DavResponseBody::Bytes(body) = propfind.body else { + panic!("PROPFIND error should have text body"); + }; + assert_eq!(body.as_ref(), b"Invalid PROPFIND body"); + + let proppatch = proppatch_xml_error_response(DavXmlError::InvalidGrammar).unwrap(); + let DavResponseBody::Bytes(body) = proppatch.body else { + panic!("PROPPATCH error should have text body"); + }; + assert_eq!(body.as_ref(), b"Invalid PROPPATCH body"); + + let too_large = propfind_xml_error_response(DavXmlError::TooLarge).unwrap(); + assert_eq!(too_large.status, StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(too_large.headers.get("Cache-Control").unwrap(), "no-store"); +} diff --git a/crates/aster_forge_webdav/tests/protocol.rs b/crates/aster_forge_webdav/tests/protocol.rs new file mode 100644 index 0000000..e7deec3 --- /dev/null +++ b/crates/aster_forge_webdav/tests/protocol.rs @@ -0,0 +1,842 @@ +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, UNIX_EPOCH}; + +use aster_forge_webdav::{ + DAV_ALLOW_HEADER, DavBackendError, DavBackendErrorKind, DavBodyPolicy, DavIfEvaluationError, + DavIfResourceState, DavIfStateResolver, DavMethod, DavPath, DavPathError, DavPrecondition, + DavProtocolErrorKind, DavRequestHead, DavRequestOrigin, Depth, IfHeader, IfStateCondition, + child_relative_path, destination_relative_path, enforce_if_header, + evaluate_http_download_preconditions, evaluate_http_etag_preconditions, href_for_relative, + parent_relative_path, parse_copy_depth, parse_delete_depth, parse_if_header, parse_lock_depth, + parse_lock_timeout, parse_lock_token_header, parse_move_depth, parse_propfind_depth, + submitted_lock_tokens, submitted_lock_tokens_for_path, +}; +use async_trait::async_trait; +use http::header::{self, HeaderMap, HeaderName, HeaderValue}; +use http::{Method, Uri}; + +fn headers(name: &'static str, value: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_bytes(name.as_bytes()).expect("test header name should be valid"), + HeaderValue::from_static(value), + ); + headers +} + +fn parsed_if(value: &'static str) -> IfHeader { + parse_if_header(&headers("If", value)) + .expect("If header should parse") + .expect("If header should exist") +} + +#[derive(Default)] +struct MockIfStateResolver { + states: HashMap, + fail_path: Option, + calls: Mutex>, +} + +#[async_trait] +impl DavIfStateResolver for MockIfStateResolver { + async fn resolve_if_state( + &self, + path: &DavPath, + ) -> Result { + self.calls + .lock() + .expect("call log lock should not be poisoned") + .push(path.as_str().to_string()); + if self.fail_path.as_deref() == Some(path.as_str()) { + return Err(DavBackendError::new(DavBackendErrorKind::Internal)); + } + Ok(self.states.get(path.as_str()).cloned().unwrap_or_default()) + } +} + +#[test] +fn dav_path_canonicalizes_dot_segments_and_rejects_escape() { + let path = DavPath::new("/projects/./docs/reports/../q1.txt") + .expect("internal dot segments should normalize"); + assert_eq!(path.as_str(), "/projects/docs/q1.txt"); + + let collection = DavPath::new("/projects/docs/reports/%2e%2e") + .expect("encoded internal parent should normalize"); + assert_eq!(collection.as_str(), "/projects/docs/"); + assert!(collection.is_collection()); + + assert!(matches!( + DavPath::new("/%2e%2e/secret.txt"), + Err(DavPathError::PathEscape) + )); + assert!(matches!( + DavPath::new("/bad%FFname"), + Err(DavPathError::InvalidEncoding) + )); +} + +#[test] +fn dav_path_rejects_percent_encoded_path_separators_before_decoding() { + for path in [ + "/folder%2Fchild", + "/folder%2fchild", + "/folder%5Cchild", + "/folder%5cchild", + ] { + assert_eq!(DavPath::new(path), Err(DavPathError::InvalidEncoding)); + } + + assert_eq!( + DavPath::new("/folder%252Fchild") + .expect("a once-decoded literal percent sequence should remain one segment") + .as_str(), + "/folder%2Fchild" + ); +} + +#[test] +fn dav_path_preserves_collection_aliases_and_internal_parent_segments() { + for value in [ + "/projects/docs/.", + "/projects/docs/reports/..", + "/projects/docs/reports/%2e%2e", + ] { + let path = DavPath::new(value).expect("collection alias should normalize"); + assert_eq!(path.as_str(), "/projects/docs/"); + assert!(path.is_collection()); + } + + let path = DavPath::new("/projects/docs/%2e%2e/manuals/file.txt") + .expect("internal encoded parent should normalize"); + assert_eq!(path.as_str(), "/projects/manuals/file.txt"); +} + +#[test] +fn dav_path_rejects_every_root_escape_shape() { + for value in [ + "/../secret.txt", + "/projects/../../secret.txt", + "/%2e%2e/secret.txt", + ] { + assert!(matches!(DavPath::new(value), Err(DavPathError::PathEscape))); + } +} + +#[test] +fn href_and_relative_path_helpers_preserve_collection_semantics() { + assert_eq!( + href_for_relative("/webdav", "/folder/file name%2B.txt"), + "/webdav/folder/file%20name%252B.txt" + ); + assert_eq!( + child_relative_path("/folder/", b"child", true).expect("valid child name"), + "/folder/child/" + ); + assert_eq!( + child_relative_path("/folder/", b"bad/name", false), + Err(DavPathError::InvalidChildName) + ); + assert_eq!( + child_relative_path("/folder/", b"bad\\name", false), + Err(DavPathError::InvalidChildName) + ); + assert_eq!( + child_relative_path("/folder/", b"bad\xffname", false), + Err(DavPathError::InvalidEncoding) + ); + assert_eq!( + parent_relative_path("/folder/child/"), + Some("/folder/".to_string()) + ); + assert_eq!(parent_relative_path("/file.txt"), Some("/".to_string())); + assert_eq!(parent_relative_path("/"), None); +} + +#[test] +fn method_parser_recognizes_webdav_extensions() { + assert_eq!(DavMethod::from_method(&Method::GET), Some(DavMethod::Get)); + assert_eq!( + DavMethod::from_method(&Method::from_bytes(b"PROPFIND").expect("valid method")), + Some(DavMethod::Propfind) + ); + assert_eq!(DavMethod::from_method(&Method::PATCH), None); +} + +#[test] +fn method_body_policies_keep_protocol_and_product_streaming_responsibilities_separate() { + for method in [ + DavMethod::Options, + DavMethod::Mkcol, + DavMethod::Delete, + DavMethod::Copy, + DavMethod::Move, + DavMethod::Unlock, + ] { + assert_eq!(method.body_policy(), DavBodyPolicy::Empty); + } + for method in [ + DavMethod::Propfind, + DavMethod::Proppatch, + DavMethod::Lock, + DavMethod::Report, + ] { + assert_eq!(method.body_policy(), DavBodyPolicy::BoundedXml); + } + assert_eq!(DavMethod::Put.body_policy(), DavBodyPolicy::Stream); + for method in [DavMethod::Get, DavMethod::Head, DavMethod::VersionControl] { + assert_eq!(method.body_policy(), DavBodyPolicy::Unused); + } +} + +#[test] +fn advertised_methods_are_exactly_the_methods_recognized_by_the_protocol_layer() { + let methods = DAV_ALLOW_HEADER.split(", ").collect::>(); + assert_eq!(methods.len(), 14); + for method in methods { + assert!(DavMethod::from_name(method).is_some(), "{method}"); + } +} + +#[test] +fn depth_rules_remain_method_specific() { + assert_eq!( + parse_propfind_depth(&HeaderMap::new()).expect("default depth"), + Depth::Infinity + ); + assert_eq!( + parse_copy_depth(&headers("Depth", "0")).expect("copy depth zero"), + Depth::Zero + ); + assert!(parse_copy_depth(&headers("Depth", "1")).is_err()); + assert_eq!( + parse_lock_depth(&HeaderMap::new()).expect("default lock depth"), + Depth::Infinity + ); + assert!(parse_lock_depth(&headers("Depth", "1")).is_err()); + assert_eq!( + parse_move_depth(&headers("Depth", "0")).expect("move depth"), + Depth::Zero + ); + assert_eq!( + parse_delete_depth(&headers("Depth", "1")).expect("delete depth"), + Depth::One + ); +} + +#[test] +fn depth_values_are_case_insensitive_but_not_whitespace_tolerant() { + assert_eq!( + parse_propfind_depth(&headers("Depth", "Infinity")).expect("case-insensitive depth"), + Depth::Infinity + ); + assert!(parse_propfind_depth(&headers("Depth", "")).is_err()); + assert!(parse_propfind_depth(&headers("Depth", " infinity ")).is_err()); + + let mut non_utf8 = HeaderMap::new(); + non_utf8.insert( + HeaderName::from_static("depth"), + HeaderValue::from_bytes(&[0xff]).expect("test header value"), + ); + assert!(parse_propfind_depth(&non_utf8).is_err()); +} + +#[test] +fn destination_is_same_origin_and_mount_scoped() { + let relative = destination_relative_path( + &headers("Destination", "/webdav/folder/file%20name.txt"), + "/webdav", + "https", + "dav.example", + ) + .expect("relative destination under mount should parse"); + assert_eq!(relative.relative, "/folder/file name.txt"); + + let absolute = destination_relative_path( + &headers( + "Destination", + "HTTPS://DAV.EXAMPLE/webdav/folder/file%20name.txt", + ), + "/webdav", + "https", + "dav.example", + ) + .expect("matching absolute destination should parse"); + assert_eq!(absolute.path.as_str(), "/folder/file name.txt"); + + destination_relative_path( + &headers("Destination", "https://dav.example:443/webdav/file.txt"), + "/webdav", + "https", + "dav.example", + ) + .expect("the default HTTPS port should not change the origin"); + destination_relative_path( + &headers("Destination", "https://user@dav.example/webdav/file.txt"), + "/webdav", + "https", + "dav.example", + ) + .expect_err("userinfo in an HTTP URI must be rejected"); + destination_relative_path( + &headers("Destination", "http://dav.example/webdav/file.txt"), + "/webdav", + "http", + "dav.example:80", + ) + .expect("an omitted HTTP port should match explicit port 80"); + + for destination in [ + "https://dav.example:444/webdav/file.txt", + "https://dav.example/webdav/file.txt", + ] { + let request_host = if destination.contains(":444") { + "dav.example" + } else { + "dav.example:444" + }; + destination_relative_path( + &headers("Destination", destination), + "/webdav", + "https", + request_host, + ) + .expect_err("a non-default port must remain part of the origin"); + } + + let cross_origin = destination_relative_path( + &headers("Destination", "https://other.example/webdav/file.txt"), + "/webdav", + "https", + "dav.example", + ) + .expect_err("cross-origin destination should fail"); + assert_eq!( + cross_origin.message(), + "Destination must stay on this WebDAV server" + ); + + let outside = destination_relative_path( + &headers("Destination", "/webdavish/file.txt"), + "/webdav", + "https", + "dav.example", + ) + .expect_err("outside mount destination should fail"); + assert_eq!( + outside.message(), + "Destination must stay under WebDAV prefix" + ); +} + +#[test] +fn destination_reports_stable_missing_and_malformed_errors() { + let missing = destination_relative_path(&HeaderMap::new(), "/webdav", "https", "dav.example") + .expect_err("missing destination should fail"); + assert_eq!(missing.message(), "Missing Destination header"); + + let malformed = destination_relative_path( + &headers("Destination", "not-an-absolute-path"), + "/webdav", + "https", + "dav.example", + ) + .expect_err("relative reference should fail"); + assert_eq!(malformed.message(), "Invalid Destination header"); +} + +#[test] +fn if_header_preserves_tagged_groups_not_and_etags() { + let parsed = parse_if_header(&headers( + "If", + r#" ( ["etag-a"]) (Not )"#, + )) + .expect("If header should parse") + .expect("If header should exist"); + + assert_eq!(parsed.groups.len(), 2); + assert_eq!( + parsed.groups[0].tagged_path.as_deref(), + Some("/webdav/a.txt") + ); + assert_eq!( + parsed.groups[1].lists[0].conditions, + [IfStateCondition::Token { + value: "urn:uuid:b".to_string(), + negated: true, + }] + ); +} + +#[test] +fn if_header_rejects_ambiguous_or_empty_grammar() { + for value in [ + "()", + r#"(Notified )"#, + r#"() ()"#, + r#" ("#, + ] { + assert!(parse_if_header(&headers("If", value)).is_err()); + } +} + +#[test] +fn if_header_accepts_case_insensitive_not_and_groups_tagged_lists() { + let parsed = parse_if_header(&headers( + "If", + r#" () () (nOt )"#, + )) + .expect("If header should parse") + .expect("If header should exist"); + + assert_eq!(parsed.groups.len(), 2); + assert_eq!(parsed.groups[0].lists.len(), 2); + assert_eq!( + parsed.groups[1].lists[0].conditions, + [IfStateCondition::Token { + value: "urn:uuid:b".to_string(), + negated: true, + }] + ); +} + +#[test] +fn if_evaluator_uses_or_between_lists_and_and_inside_each_list() { + let request_path = DavPath::new("/current.txt").expect("request path"); + let resolver = MockIfStateResolver { + states: HashMap::from([( + "/current.txt".to_string(), + DavIfResourceState { + etag: Some("etag-1".to_string()), + lock_tokens: vec!["urn:uuid:one".to_string()], + }, + )]), + ..MockIfStateResolver::default() + }; + let matching = + parsed_if(r#"() ( ["etag-1"] Not )"#); + futures::executor::block_on(enforce_if_header( + Some(&matching), + &resolver, + &request_path, + "/webdav", + "https", + "dav.example", + )) + .expect("one complete state list should satisfy the header"); + + let mismatched = parsed_if(r#"( ["other"])"#); + let error = futures::executor::block_on(enforce_if_header( + Some(&mismatched), + &resolver, + &request_path, + "/webdav", + "https", + "dav.example", + )) + .expect_err("every condition in one list must match"); + assert!(matches!( + error, + DavIfEvaluationError::Protocol(error) + if error.kind() == DavProtocolErrorKind::PreconditionFailed + )); +} + +#[test] +fn if_evaluator_uses_or_between_tagged_resource_groups() { + let request_path = DavPath::new("/request.txt").expect("request path"); + let resolver = MockIfStateResolver { + states: HashMap::from([ + ( + "/a.txt".to_string(), + DavIfResourceState { + etag: None, + lock_tokens: vec!["urn:uuid:a".to_string()], + }, + ), + ( + "/b.txt".to_string(), + DavIfResourceState { + etag: None, + lock_tokens: vec!["urn:uuid:b".to_string()], + }, + ), + ]), + ..MockIfStateResolver::default() + }; + let matching = parsed_if(r#" () ()"#); + futures::executor::block_on(enforce_if_header( + Some(&matching), + &resolver, + &request_path, + "/webdav", + "https", + "dav.example", + )) + .expect("all tagged resource groups match"); + + let partially_matching = + parsed_if(r#" () ()"#); + futures::executor::block_on(enforce_if_header( + Some(&partially_matching), + &resolver, + &request_path, + "/webdav", + "https", + "dav.example", + )) + .expect("one matching tagged-list production satisfies the complete If header"); + + let mismatched = + parsed_if(r#" () ()"#); + let error = futures::executor::block_on(enforce_if_header( + Some(&mismatched), + &resolver, + &request_path, + "/webdav", + "https", + "dav.example", + )); + assert!(matches!( + error, + Err(DavIfEvaluationError::Protocol(error)) + if error.kind() == DavProtocolErrorKind::PreconditionFailed + )); +} + +#[test] +fn if_evaluator_scopes_tagged_uris_before_calling_the_product_resolver() { + let request_path = DavPath::new("/request.txt").expect("request path"); + let resolver = MockIfStateResolver::default(); + let outside_origin = parsed_if(r#" (Not )"#); + futures::executor::block_on(enforce_if_header( + Some(&outside_origin), + &resolver, + &request_path, + "/webdav", + "https", + "dav.example", + )) + .expect("negated state should match an out-of-scope resource"); + assert!(resolver.calls.lock().expect("call log lock").is_empty()); + + let decoded = parsed_if(r#" (Not )"#); + futures::executor::block_on(enforce_if_header( + Some(&decoded), + &resolver, + &request_path, + "/webdav", + "https", + "dav.example", + )) + .expect("mount-scoped percent-encoded path should resolve"); + assert_eq!( + resolver.calls.lock().expect("call log lock").as_slice(), + ["/current file.txt"] + ); +} + +#[test] +fn if_evaluator_separates_protocol_and_backend_failures() { + let request_path = DavPath::new("/request.txt").expect("request path"); + let invalid = parsed_if(r#" (Not )"#); + let resolver = MockIfStateResolver::default(); + let error = futures::executor::block_on(enforce_if_header( + Some(&invalid), + &resolver, + &request_path, + "/webdav", + "https", + "dav.example", + )); + assert!(matches!( + error, + Err(DavIfEvaluationError::Protocol(error)) + if error.kind() == DavProtocolErrorKind::BadRequest + )); + + let resolver = MockIfStateResolver { + fail_path: Some("/request.txt".to_string()), + ..MockIfStateResolver::default() + }; + let header = parsed_if(r#"(Not )"#); + let error = futures::executor::block_on(enforce_if_header( + Some(&header), + &resolver, + &request_path, + "/webdav", + "https", + "dav.example", + )); + assert!(matches!( + error, + Err(DavIfEvaluationError::Backend(error)) + if error.kind == DavBackendErrorKind::Internal + )); +} + +#[test] +fn submitted_tokens_apply_only_to_matching_tagged_resource() { + let headers = headers( + "If", + r#" () ()"#, + ); + assert_eq!( + submitted_lock_tokens_for_path(&headers, "/webdav/current.txt", "http", "localhost:8080"), + ["urn:uuid:current".to_string()] + ); +} + +#[test] +fn submitted_tokens_are_decoded_deduplicated_and_include_negated_conditions() { + let headers = headers( + "If", + r#" () () (Not )"#, + ); + assert_eq!( + submitted_lock_tokens_for_path(&headers, "/webdav/current file.txt", "http", "localhost"), + ["urn:uuid:current".to_string(), "urn:uuid:other".to_string()] + ); +} + +#[test] +fn submitted_tokens_ignore_other_resources_and_lock_token_header() { + let mut headers = headers( + "If", + r#" () ()"#, + ); + headers.insert( + HeaderName::from_static("lock-token"), + HeaderValue::from_static(""), + ); + assert_eq!( + submitted_lock_tokens_for_path(&headers, "/webdav/current.txt", "http", "localhost"), + ["urn:uuid:current".to_string()] + ); +} + +#[test] +fn submitted_tokens_from_parsed_if_header_preserve_scope_and_deduplicate() { + let untagged = parse_if_header(&headers("If", r#"()"#)) + .expect("untagged If header should parse") + .expect("If header should exist"); + assert_eq!( + submitted_lock_tokens( + &untagged, + "/webdav/current file.txt", + "https", + "dav.example" + ), + ["urn:uuid:untagged".to_string()] + ); + + let tagged = parse_if_header(&headers( + "If", + r#" () () (Not ) () ()"#, + )) + .expect("If header should parse") + .expect("If header should exist"); + + assert_eq!( + submitted_lock_tokens(&tagged, "/webdav/current file.txt", "https", "dav.example"), + [ + "urn:uuid:current".to_string(), + "urn:uuid:negated".to_string(), + ] + ); +} + +#[test] +fn lock_timeout_uses_bounded_server_policy() { + let maximum = Duration::from_secs(604_800); + assert_eq!( + parse_lock_timeout(&HeaderMap::new(), maximum).expect("missing timeout should use maximum"), + maximum + ); + assert_eq!( + parse_lock_timeout(&headers("Timeout", "Infinite"), maximum) + .expect("Infinite should use maximum"), + maximum + ); + assert_eq!( + parse_lock_timeout(&headers("Timeout", "Second-3600"), maximum) + .expect("bounded timeout should parse"), + Duration::from_secs(3600) + ); + assert_eq!( + parse_lock_timeout(&headers("Timeout", "Second-604800"), maximum) + .expect("exact maximum should parse"), + maximum + ); + assert_eq!( + parse_lock_timeout(&headers("Timeout", "Extension, Second-60"), maximum) + .expect("unknown candidate should not hide a valid timeout"), + Duration::from_secs(60) + ); + + for value in ["Second-604801", "Second-18446744073709551615"] { + assert_eq!( + parse_lock_timeout(&headers("Timeout", value), maximum) + .expect("an oversized numeric timeout should be clamped"), + maximum + ); + } + assert!(parse_lock_timeout(&headers("Timeout", "Extension"), maximum).is_err()); + + let mut non_utf8 = HeaderMap::new(); + non_utf8.insert( + HeaderName::from_static("timeout"), + HeaderValue::from_bytes(&[0xff]).expect("test header value"), + ); + assert!(parse_lock_timeout(&non_utf8, maximum).is_err()); +} + +#[test] +fn lock_token_header_requires_one_nonempty_angle_bracketed_token() { + assert_eq!( + parse_lock_token_header(&headers("Lock-Token", " ")) + .expect("valid lock token should parse"), + "urn:uuid:lock" + ); + + for value in ["", "<>", "urn:uuid:lock", "<>", ""] { + assert!( + parse_lock_token_header(&headers("Lock-Token", value)).is_err(), + "{value:?} should be rejected" + ); + } + assert!(parse_lock_token_header(&HeaderMap::new()).is_err()); + + let mut non_utf8 = HeaderMap::new(); + non_utf8.insert( + HeaderName::from_static("lock-token"), + HeaderValue::from_bytes(&[0xff]).expect("test header value"), + ); + assert!(parse_lock_token_header(&non_utf8).is_err()); +} + +#[test] +fn etag_and_date_preconditions_keep_http_precedence() { + let mut matching = HeaderMap::new(); + matching.insert(header::IF_NONE_MATCH, HeaderValue::from_static("\"v1\"")); + assert_eq!( + evaluate_http_etag_preconditions(&matching, true, Some("\"v1\""), true) + .expect("safe matching If-None-Match"), + DavPrecondition::NotModified + ); + assert!(evaluate_http_etag_preconditions(&matching, true, Some("\"v1\""), false).is_err()); + + let modified = UNIX_EPOCH + Duration::from_secs(2_000_000); + let mut download = HeaderMap::new(); + download.insert( + header::IF_MODIFIED_SINCE, + HeaderValue::from_static("Sat, 24 Jan 1970 03:33:20 GMT"), + ); + assert_eq!( + evaluate_http_download_preconditions(&download, None, Some(modified)) + .expect("valid date precondition"), + DavPrecondition::NotModified + ); + + let mut if_match_precedence = HeaderMap::new(); + if_match_precedence.insert(header::IF_MATCH, HeaderValue::from_static("\"v1\"")); + if_match_precedence.insert( + header::IF_UNMODIFIED_SINCE, + HeaderValue::from_static("Thu, 01 Jan 1970 00:00:00 GMT"), + ); + assert_eq!( + evaluate_http_download_preconditions(&if_match_precedence, Some("\"v1\""), Some(modified),) + .expect("a matching If-Match suppresses If-Unmodified-Since"), + DavPrecondition::Proceed + ); + + let mut if_none_match_precedence = HeaderMap::new(); + if_none_match_precedence.insert( + header::IF_NONE_MATCH, + HeaderValue::from_static("\"different\""), + ); + if_none_match_precedence.insert( + header::IF_MODIFIED_SINCE, + HeaderValue::from_static("Sat, 24 Jan 1970 03:33:20 GMT"), + ); + assert_eq!( + evaluate_http_download_preconditions( + &if_none_match_precedence, + Some("\"v1\""), + Some(modified), + ) + .expect("a nonmatching If-None-Match suppresses If-Modified-Since"), + DavPrecondition::Proceed + ); +} + +#[test] +fn request_head_parses_method_specific_contract() { + let method = DavMethod::from_method( + &Method::from_bytes(b"COPY").expect("COPY should be a valid HTTP extension method"), + ) + .expect("COPY should be supported"); + let uri: Uri = "/webdav/source.txt".parse().expect("valid request URI"); + let mut request_headers = headers("Destination", "/webdav/destination.txt"); + request_headers.insert("Depth", HeaderValue::from_static("0")); + request_headers.insert("Overwrite", HeaderValue::from_static("F")); + + let request = DavRequestHead::parse( + method, + &uri, + &request_headers, + "/webdav", + &DavRequestOrigin { + scheme: "https".to_string(), + host: "dav.example".to_string(), + }, + ) + .expect("COPY request head should parse"); + + assert_eq!(request.target.as_str(), "/source.txt"); + assert_eq!(request.origin.scheme, "https"); + assert_eq!(request.origin.host, "dav.example"); + assert_eq!(request.depth, Some(Depth::Zero)); + assert_eq!(request.overwrite, Some(false)); + assert_eq!( + request.destination.expect("destination").path.as_str(), + "/destination.txt" + ); +} + +#[test] +fn request_head_rejects_targets_outside_the_mount() { + let uri: Uri = "/webdavish/source.txt".parse().expect("valid request URI"); + let error = DavRequestHead::parse( + DavMethod::Get, + &uri, + &HeaderMap::new(), + "/webdav", + &DavRequestOrigin { + scheme: "https".to_string(), + host: "dav.example".to_string(), + }, + ) + .expect_err("lookalike mount prefix must be rejected"); + assert_eq!( + error.message(), + "Request target must stay under WebDAV prefix" + ); +} + +#[test] +fn request_head_accepts_a_root_mount() { + let uri: Uri = "/folder/source.txt".parse().expect("valid request URI"); + let request = DavRequestHead::parse( + DavMethod::Get, + &uri, + &HeaderMap::new(), + "/", + &DavRequestOrigin { + scheme: "https".to_string(), + host: "dav.example".to_string(), + }, + ) + .expect("root-mounted WebDAV should accept descendant paths"); + assert_eq!(request.target.as_str(), "/folder/source.txt"); +} diff --git a/crates/aster_forge_webdav/tests/put.rs b/crates/aster_forge_webdav/tests/put.rs new file mode 100644 index 0000000..bf33308 --- /dev/null +++ b/crates/aster_forge_webdav/tests/put.rs @@ -0,0 +1,74 @@ +use aster_forge_webdav::{ + DavPath, DavProtocolErrorKind, DavPutPlanError, DavPutResourceState, plan_put_request, + put_plan_error_response, put_success_response, +}; +use http::header::{CONTENT_LENGTH, CONTENT_LOCATION, IF_MATCH, IF_NONE_MATCH}; +use http::{HeaderMap, HeaderValue, StatusCode}; + +#[test] +fn put_plan_selects_create_modes_and_expected_length_precedence() { + let mut headers = HeaderMap::new(); + headers.insert(IF_NONE_MATCH, HeaderValue::from_static("*")); + headers.insert(CONTENT_LENGTH, HeaderValue::from_static("4")); + headers.insert("X-Expected-Entity-Length", HeaderValue::from_static("8")); + let plan = plan_put_request(&headers, DavPutResourceState::Missing).expect("PUT plan"); + assert!(!plan.resource_existed); + assert!(plan.create); + assert!(plan.create_new); + assert_eq!(plan.content_length_hint, Some(8)); + + headers.insert( + "X-Expected-Entity-Length", + HeaderValue::from_static("invalid"), + ); + let plan = plan_put_request(&headers, DavPutResourceState::Missing).expect("fallback plan"); + assert_eq!(plan.content_length_hint, Some(4)); +} + +#[test] +fn put_plan_enforces_etag_preconditions_and_collection_target() { + let mut headers = HeaderMap::new(); + headers.insert(IF_MATCH, HeaderValue::from_static("*")); + let error = plan_put_request(&headers, DavPutResourceState::Missing) + .expect_err("If-Match star requires an existing resource"); + assert!(matches!( + error, + DavPutPlanError::Protocol(error) + if error.kind() == DavProtocolErrorKind::PreconditionFailed + )); + + let mut headers = HeaderMap::new(); + headers.insert(IF_NONE_MATCH, HeaderValue::from_static("*")); + let error = plan_put_request(&headers, DavPutResourceState::File { etag: Some("a") }) + .expect_err("If-None-Match star rejects an existing resource"); + assert!(matches!(error, DavPutPlanError::Protocol(_))); + + assert_eq!( + plan_put_request(&HeaderMap::new(), DavPutResourceState::Collection), + Err(DavPutPlanError::CollectionTarget) + ); + let response = put_plan_error_response(&DavPutPlanError::CollectionTarget); + assert_eq!(response.status, StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(response.headers.get("Cache-Control").unwrap(), "no-store"); +} + +#[test] +fn put_success_response_selects_created_or_no_content() { + let path = DavPath::new("/space file.txt").expect("path"); + let created_plan = + plan_put_request(&HeaderMap::new(), DavPutResourceState::Missing).expect("created plan"); + let created = put_success_response(&created_plan, "/webdav", &path).expect("created response"); + assert_eq!(created.status, StatusCode::CREATED); + assert_eq!( + created.headers.get(CONTENT_LOCATION).unwrap(), + "/webdav/space%20file.txt" + ); + + let replaced_plan = + plan_put_request(&HeaderMap::new(), DavPutResourceState::File { etag: None }) + .expect("replace plan"); + let replaced = + put_success_response(&replaced_plan, "/webdav", &path).expect("replace response"); + assert_eq!(replaced.status, StatusCode::NO_CONTENT); + assert!(replaced.headers.get(CONTENT_LOCATION).is_none()); +} diff --git a/crates/aster_forge_webdav/tests/resource.rs b/crates/aster_forge_webdav/tests/resource.rs new file mode 100644 index 0000000..702fd54 --- /dev/null +++ b/crates/aster_forge_webdav/tests/resource.rs @@ -0,0 +1,178 @@ +use aster_forge_webdav::{ + DavCopyMoveMethod, DavMutationFailure, DavMutationPlanError, DavPath, DavResourceKind, + DavResponseBody, Depth, collection_created_response, delete_success_response, + is_descendant_path, mutation_multistatus_response, mutation_plan_error_response, + mutation_success_response, plan_copy_move_request, replace_relative_prefix, + resource_identity_path, same_resource_path, validate_collection_create_target, + validate_delete_target, +}; +use http::StatusCode; + +#[test] +fn resource_identity_and_descendant_rules_use_path_boundaries() { + assert_eq!(resource_identity_path("/"), "/"); + assert_eq!(resource_identity_path("/docs///"), "/docs"); + assert!(same_resource_path("/docs", "/docs/")); + assert!(!same_resource_path("/docs", "/docs-2")); + assert!(is_descendant_path("/docs/", "/docs/sub/file.txt")); + assert!(!is_descendant_path("/docs", "/docs-2/file.txt")); + assert!(!is_descendant_path("/docs", "/docs")); + assert!(!is_descendant_path("/", "/docs")); +} + +#[test] +fn recursive_mutation_path_rebasing_respects_segment_and_collection_boundaries() { + assert_eq!( + replace_relative_prefix("/docs/docs/file.txt", "/docs", "/archive"), + "/archive/docs/file.txt" + ); + assert_eq!( + replace_relative_prefix("/docs/", "/docs/", "/archive/"), + "/archive/" + ); + assert_eq!( + replace_relative_prefix("/docs/sub/", "/docs/", "/archive/"), + "/archive/sub/" + ); + assert_eq!( + replace_relative_prefix("/docs2/file.txt", "/docs", "/archive"), + "/archive/docs2/file.txt" + ); +} + +#[test] +fn mutation_success_selects_created_or_no_content_with_no_store() { + let created = mutation_success_response(false); + assert_eq!(created.status, StatusCode::CREATED); + assert_eq!(created.headers.get("Cache-Control").unwrap(), "no-store"); + + let replaced = mutation_success_response(true); + assert_eq!(replaced.status, StatusCode::NO_CONTENT); + assert_eq!(replaced.headers.get("Cache-Control").unwrap(), "no-store"); +} + +#[test] +fn partial_failures_compose_locked_dav_error_and_plain_status_items() { + let response = mutation_multistatus_response( + "/webdav", + &[ + DavMutationFailure::locked( + DavPath::new("/locked.txt").expect("path"), + DavPath::new("/parent/").expect("lock path"), + ), + DavMutationFailure::status( + DavPath::new("/missing.txt").expect("path"), + StatusCode::NOT_FOUND.as_u16(), + ), + ], + ) + .expect("multistatus response"); + assert_eq!(response.status, StatusCode::MULTI_STATUS); + assert_eq!(response.headers.get("Cache-Control").unwrap(), "no-store"); + let DavResponseBody::Bytes(body) = response.body else { + panic!("multistatus should have an XML body"); + }; + let xml = String::from_utf8(body.to_vec()).expect("UTF-8 XML"); + assert!(xml.contains("lock-token-submitted"), "{xml}"); + assert!(xml.contains("/webdav/parent/"), "{xml}"); + assert!(xml.contains("HTTP/1.1 404 Not Found"), "{xml}"); +} + +#[test] +fn resource_shape_planner_enforces_depth_path_and_overwrite_rules() { + assert_eq!( + validate_delete_target(DavResourceKind::Collection, Depth::Zero), + Err(DavMutationPlanError::BadRequest) + ); + validate_delete_target(DavResourceKind::File, Depth::Zero).expect("file DELETE ignores Depth"); + + let cases = [ + ( + DavCopyMoveMethod::Move, + Depth::Zero, + "/docs", + "/archive", + true, + DavMutationPlanError::BadRequest, + ), + ( + DavCopyMoveMethod::Copy, + Depth::One, + "/docs", + "/archive", + true, + DavMutationPlanError::BadRequest, + ), + ( + DavCopyMoveMethod::Copy, + Depth::Infinity, + "/docs", + "/docs/sub", + true, + DavMutationPlanError::Forbidden, + ), + ( + DavCopyMoveMethod::Copy, + Depth::Infinity, + "/docs", + "/archive", + false, + DavMutationPlanError::PreconditionFailed, + ), + ]; + for (method, depth, source, destination, overwrite, expected) in cases { + assert_eq!( + plan_copy_move_request( + method, + depth, + DavResourceKind::Collection, + Some(DavResourceKind::Collection), + source, + destination, + overwrite, + ), + Err(expected) + ); + assert_eq!( + mutation_plan_error_response(expected) + .headers + .get("Cache-Control") + .unwrap(), + "no-store" + ); + } + + let shallow = plan_copy_move_request( + DavCopyMoveMethod::Copy, + Depth::Zero, + DavResourceKind::Collection, + None, + "/docs", + "/archive", + true, + ) + .expect("shallow copy"); + assert!(!shallow.recursive_collection); + assert!(!shallow.destination_deep); +} + +#[test] +fn mkcol_and_delete_success_responses_are_protocol_owned() { + assert_eq!( + validate_collection_create_target("/"), + Err(DavMutationPlanError::MethodNotAllowed) + ); + validate_collection_create_target("/space folder/").expect("non-root MKCOL target"); + assert_eq!( + mutation_plan_error_response(DavMutationPlanError::Conflict).status, + StatusCode::CONFLICT + ); + let path = DavPath::new("/space folder/").expect("path"); + let created = collection_created_response("/webdav", &path).expect("MKCOL response"); + assert_eq!(created.status, StatusCode::CREATED); + assert_eq!( + created.headers.get("Content-Location").unwrap(), + "/webdav/space%20folder/" + ); + assert_eq!(delete_success_response().status, StatusCode::NO_CONTENT); +} diff --git a/crates/aster_forge_webdav/tests/response.rs b/crates/aster_forge_webdav/tests/response.rs new file mode 100644 index 0000000..abbae65 --- /dev/null +++ b/crates/aster_forge_webdav/tests/response.rs @@ -0,0 +1,369 @@ +use std::time::{Duration, UNIX_EPOCH}; + +use aster_forge_webdav::{ + DAV_ALLOW_HEADER, DavBackendError, DavBackendErrorKind, DavBodyError, DavDownloadBody, + DavDownloadPlanError, DavMethod, DavProtocolErrorKind, DavRequestHead, DavRequestOrigin, + DavResponseBody, backend_error_response, body_error_response, method_not_allowed_response, + options_response, plan_download_response, protocol_error_response, + range_not_satisfiable_response, +}; +use http::StatusCode; +use http::header::{ + ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, + CONTENT_TYPE, ETAG, IF_MATCH, IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, RANGE, +}; +use http::{HeaderMap, HeaderValue, Uri}; + +fn representation_time() -> std::time::SystemTime { + UNIX_EPOCH + Duration::from_secs(784_111_777) +} + +#[test] +fn options_and_method_not_allowed_share_the_canonical_method_set() { + let options = options_response(); + assert_eq!(options.status, StatusCode::OK); + assert_eq!(options.headers.get(ALLOW).unwrap(), DAV_ALLOW_HEADER); + assert_eq!(options.headers.get("DAV").unwrap(), "1, 2, version-control"); + assert_eq!(options.headers.get("MS-Author-Via").unwrap(), "DAV"); + + let rejected = method_not_allowed_response(); + assert_eq!(rejected.status, StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(rejected.headers.get(ALLOW).unwrap(), DAV_ALLOW_HEADER); + assert_eq!(rejected.headers.get(CACHE_CONTROL).unwrap(), "no-store"); +} + +#[test] +fn body_policy_errors_preserve_status_body_and_cache_contracts() { + let read = body_error_response(DavBodyError::ReadFailed); + assert_eq!(read.status, StatusCode::BAD_REQUEST); + assert_eq!(read.headers.get(CACHE_CONTROL).unwrap(), "no-store"); + assert_eq!( + read.headers.get(CONTENT_TYPE).unwrap(), + "text/plain; charset=utf-8" + ); + assert!(matches!(read.body, DavResponseBody::Bytes(_))); + + let too_large = body_error_response(DavBodyError::XmlTooLarge); + assert_eq!(too_large.status, StatusCode::PAYLOAD_TOO_LARGE); + assert!(matches!(too_large.body, DavResponseBody::Bytes(_))); + + let not_allowed = body_error_response(DavBodyError::BodyNotAllowed); + assert_eq!(not_allowed.status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert!(not_allowed.headers.get(CONTENT_TYPE).is_none()); + assert!(matches!(not_allowed.body, DavResponseBody::Empty)); +} + +#[test] +fn full_get_plan_contains_complete_response_and_storage_contract() { + let plan = plan_download_response( + &HeaderMap::new(), + false, + 20, + "application/octet-stream", + Some("etag-1"), + representation_time(), + ) + .expect("full GET should plan"); + + assert_eq!(plan.response.status, StatusCode::OK); + assert_eq!(plan.body, DavDownloadBody::Full); + assert_eq!(plan.response.headers.get(CONTENT_LENGTH).unwrap(), "20"); + assert_eq!( + plan.response.headers.get(CONTENT_TYPE).unwrap(), + "application/octet-stream" + ); + assert_eq!(plan.response.headers.get(ACCEPT_RANGES).unwrap(), "bytes"); + assert_eq!( + plan.response.headers.get(CONTENT_ENCODING).unwrap(), + "identity" + ); + assert_eq!(plan.response.headers.get(ETAG).unwrap(), "\"etag-1\""); + assert_eq!( + plan.response.headers.get(LAST_MODIFIED).unwrap(), + "Sun, 06 Nov 1994 08:49:37 GMT" + ); + assert!(matches!(plan.response.body, DavResponseBody::Empty)); +} + +#[test] +fn ranged_get_plan_selects_exact_storage_offset_and_response_headers() { + let mut headers = HeaderMap::new(); + headers.insert(RANGE, HeaderValue::from_static(" BYTES=5-99")); + let plan = plan_download_response( + &headers, + false, + 20, + "video/mp4", + None, + representation_time(), + ) + .expect("range GET should plan"); + + assert_eq!(plan.response.status, StatusCode::PARTIAL_CONTENT); + let DavDownloadBody::Range(range) = plan.body else { + panic!("range GET should select a storage range"); + }; + assert_eq!((range.start(), range.length()), (5, 15)); + assert_eq!(plan.response.headers.get(CONTENT_LENGTH).unwrap(), "15"); + assert_eq!( + plan.response.headers.get(CONTENT_RANGE).unwrap(), + "bytes 5-19/20" + ); +} + +#[test] +fn head_ignores_even_an_unsatisfiable_range_and_never_selects_a_body() { + let mut headers = HeaderMap::new(); + headers.insert(RANGE, HeaderValue::from_static("bytes=20-99")); + let plan = plan_download_response( + &headers, + true, + 20, + "text/plain", + None, + representation_time(), + ) + .expect("HEAD should ignore Range"); + + assert_eq!(plan.response.status, StatusCode::OK); + assert_eq!(plan.body, DavDownloadBody::Empty); + assert_eq!(plan.response.headers.get(CONTENT_LENGTH).unwrap(), "20"); + assert!(plan.response.headers.get(CONTENT_RANGE).is_none()); +} + +#[test] +fn malformed_unsatisfiable_and_empty_ranges_return_complete_416_shells() { + for (raw, content_length) in [("bytes=-0", 20), ("bytes=20-", 20), ("bytes=0-0", 0)] { + let mut headers = HeaderMap::new(); + headers.insert(RANGE, HeaderValue::from_static(raw)); + let plan = plan_download_response( + &headers, + false, + content_length, + "application/octet-stream", + None, + representation_time(), + ) + .expect("invalid range should produce a response plan"); + + assert_eq!(plan.response.status, StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(plan.body, DavDownloadBody::Empty); + assert_eq!( + plan.response + .headers + .get(CONTENT_RANGE) + .unwrap() + .to_str() + .unwrap(), + format!("bytes */{content_length}") + ); + assert_eq!(plan.response.headers.get(ACCEPT_RANGES).unwrap(), "bytes"); + assert_eq!( + plan.response.headers.get(CACHE_CONTROL).unwrap(), + "no-store" + ); + } +} + +#[test] +fn unsupported_and_multiple_ranges_fall_back_to_the_complete_representation() { + for raw in ["items=0-1", "bytes=0-1,3-4"] { + let mut headers = HeaderMap::new(); + headers.insert(RANGE, HeaderValue::from_static(raw)); + let plan = plan_download_response( + &headers, + false, + 20, + "application/octet-stream", + None, + representation_time(), + ) + .expect("unsupported range form should be ignored"); + + assert_eq!(plan.response.status, StatusCode::OK); + assert_eq!(plan.body, DavDownloadBody::Full); + assert_eq!(plan.response.headers.get(CONTENT_LENGTH).unwrap(), "20"); + assert!(plan.response.headers.get(CONTENT_RANGE).is_none()); + } +} + +#[test] +fn if_range_requires_a_matching_strong_validator() { + for (if_range, expected_status, expected_body) in [ + ( + "\"etag-1\"", + StatusCode::PARTIAL_CONTENT, + DavDownloadBody::Range( + aster_forge_utils::http_range::HttpByteRange::new(0, 1, 20).unwrap(), + ), + ), + ("\"other\"", StatusCode::OK, DavDownloadBody::Full), + ("W/\"etag-1\"", StatusCode::OK, DavDownloadBody::Full), + ("not-a-validator", StatusCode::OK, DavDownloadBody::Full), + ( + "Sun, 06 Nov 1994 08:49:37 GMT", + StatusCode::PARTIAL_CONTENT, + DavDownloadBody::Range( + aster_forge_utils::http_range::HttpByteRange::new(0, 1, 20).unwrap(), + ), + ), + ( + "Sun, 06 Nov 1994 08:49:36 GMT", + StatusCode::OK, + DavDownloadBody::Full, + ), + ] { + let mut headers = HeaderMap::new(); + headers.insert(RANGE, HeaderValue::from_static("bytes=0-1")); + headers.insert( + IF_RANGE, + HeaderValue::from_str(if_range).expect("valid test header"), + ); + let plan = plan_download_response( + &headers, + false, + 20, + "application/octet-stream", + Some("etag-1"), + representation_time(), + ) + .expect("If-Range should plan"); + + assert_eq!(plan.response.status, expected_status, "{if_range}"); + assert_eq!(plan.body, expected_body, "{if_range}"); + } +} + +#[test] +fn conditional_downloads_plan_304_or_propagate_precondition_failure() { + let mut not_modified = HeaderMap::new(); + not_modified.insert(IF_NONE_MATCH, HeaderValue::from_static("\"etag-1\"")); + let plan = plan_download_response( + ¬_modified, + false, + 20, + "text/plain", + Some("etag-1"), + representation_time(), + ) + .expect("matching If-None-Match should plan 304"); + assert_eq!(plan.response.status, StatusCode::NOT_MODIFIED); + assert_eq!(plan.body, DavDownloadBody::Empty); + assert_eq!(plan.response.headers.get(ETAG).unwrap(), "\"etag-1\""); + assert!(plan.response.headers.get(CONTENT_LENGTH).is_none()); + + let mut failed = HeaderMap::new(); + failed.insert(IF_MATCH, HeaderValue::from_static("\"other\"")); + let error = match plan_download_response( + &failed, + false, + 20, + "text/plain", + Some("etag-1"), + representation_time(), + ) { + Err(error) => error, + Ok(_) => panic!("mismatched If-Match should fail"), + }; + let DavDownloadPlanError::Protocol(error) = error else { + panic!("precondition failure should remain a protocol error"); + }; + assert_eq!(error.kind(), DavProtocolErrorKind::PreconditionFailed); +} + +#[test] +fn invalid_product_metadata_is_not_misclassified_as_a_request_error() { + let error = match plan_download_response( + &HeaderMap::new(), + false, + 20, + "text/plain\ninvalid", + None, + representation_time(), + ) { + Err(error) => error, + Ok(_) => panic!("invalid content type should fail response planning"), + }; + assert_eq!(error, DavDownloadPlanError::InvalidRepresentation); + + let error = match plan_download_response( + &HeaderMap::new(), + false, + 20, + "text/plain", + Some("etag\ninvalid"), + representation_time(), + ) { + Err(error) => error, + Ok(_) => panic!("invalid ETag should fail response planning"), + }; + assert_eq!(error, DavDownloadPlanError::InvalidRepresentation); +} + +#[test] +fn direct_416_builder_handles_zero_and_maximum_representation_lengths() { + for content_length in [0, u64::MAX] { + let response = range_not_satisfiable_response(content_length); + assert_eq!(response.status, StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!( + response + .headers + .get(CONTENT_RANGE) + .unwrap() + .to_str() + .unwrap(), + format!("bytes */{content_length}") + ); + } +} + +#[test] +fn protocol_and_backend_failures_are_mapped_by_forge() { + let error = DavRequestHead::parse( + DavMethod::Get, + &"/outside".parse::().expect("URI"), + &HeaderMap::new(), + "/webdav", + &DavRequestOrigin { + scheme: "https".to_owned(), + host: "dav.example".to_owned(), + }, + ) + .expect_err("outside mount should fail"); + let protocol = protocol_error_response(&error); + assert_eq!(protocol.status, StatusCode::BAD_REQUEST); + assert_eq!(protocol.headers.get(CACHE_CONTROL).unwrap(), "no-store"); + assert_eq!( + protocol.headers.get(CONTENT_TYPE).unwrap(), + "text/plain; charset=utf-8" + ); + + for (kind, expected) in [ + (DavBackendErrorKind::NotFound, StatusCode::NOT_FOUND), + (DavBackendErrorKind::Forbidden, StatusCode::FORBIDDEN), + (DavBackendErrorKind::Conflict, StatusCode::CONFLICT), + (DavBackendErrorKind::AlreadyExists, StatusCode::CONFLICT), + ( + DavBackendErrorKind::InsufficientStorage, + StatusCode::INSUFFICIENT_STORAGE, + ), + ( + DavBackendErrorKind::PayloadTooLarge, + StatusCode::PAYLOAD_TOO_LARGE, + ), + (DavBackendErrorKind::Locked, StatusCode::LOCKED), + (DavBackendErrorKind::InvalidInput, StatusCode::BAD_REQUEST), + ( + DavBackendErrorKind::Unsupported, + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + DavBackendErrorKind::Internal, + StatusCode::INTERNAL_SERVER_ERROR, + ), + ] { + let response = backend_error_response(&DavBackendError::new(kind)); + assert_eq!(response.status, expected); + assert_eq!(response.headers.get(CACHE_CONTROL).unwrap(), "no-store"); + } +} diff --git a/crates/aster_forge_webdav/tests/xml.rs b/crates/aster_forge_webdav/tests/xml.rs new file mode 100644 index 0000000..66fd44d --- /dev/null +++ b/crates/aster_forge_webdav/tests/xml.rs @@ -0,0 +1,435 @@ +use std::io::{self, Cursor, Read}; + +use aster_forge_webdav::{ + DavPropfindRequest, DavXmlElement, DavXmlError, DavXmlNode, parse_lock_request, + parse_propfind_request, parse_proppatch_request, parse_report_root, +}; +use aster_forge_xml::{DEFAULT_XML_MAX_DEPTH, XmlSafetyPolicy}; + +struct FailingReader; + +impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::other("fixture read failure")) + } +} + +#[test] +fn propfind_absent_body_and_namespace_forms() { + assert_eq!( + parse_propfind_request(b"").unwrap(), + DavPropfindRequest::AllProp { + include: Vec::new() + } + ); + + for xml in [ + br#""#.as_slice(), + br#""#, + br#""#, + ] { + assert_eq!( + parse_propfind_request(xml).unwrap(), + DavPropfindRequest::PropName + ); + } +} + +#[test] +fn propfind_qname_collisions_do_not_activate_dav_controls() { + for xml in [ + br#""#.as_slice(), + br#""#, + br#""#, + ] { + assert_eq!( + parse_propfind_request(xml), + Err(DavXmlError::InvalidGrammar) + ); + } +} + +#[test] +fn propfind_unknown_attributes_children_and_subtrees_are_ignored() { + let request = parse_propfind_request( + br#" + + + + "#, + ) + .unwrap(); + assert_eq!( + request, + DavPropfindRequest::AllProp { + include: Vec::new() + } + ); +} + +#[test] +fn propfind_include_preserves_qnames_and_order() { + let request = parse_propfind_request( + br#" + + + "#, + ) + .unwrap(); + let DavPropfindRequest::AllProp { include } = request else { + panic!("expected allprop"); + }; + assert_eq!(include.len(), 3); + assert_eq!(include[0].name, "getetag"); + assert_eq!(include[0].namespace.as_deref(), Some("DAV:")); + assert_eq!(include[1].name, "color"); + assert_eq!(include[1].namespace.as_deref(), Some("urn:a")); + assert_eq!(include[2].name, "plain"); + assert_eq!(include[2].namespace, None); +} + +#[test] +fn propfind_rejects_duplicates_and_mutually_exclusive_selectors() { + for xml in [ + br#""#.as_slice(), + br#""#, + br#""#, + br#""#, + br#""#, + ] { + assert_eq!( + parse_propfind_request(xml), + Err(DavXmlError::InvalidGrammar) + ); + } +} + +#[test] +fn all_xml_parsers_reject_empty_whitespace_declaration_and_multiple_roots() { + for xml in [ + b"".as_slice(), + b" \r\n\t", + br#""#, + b"", + b"<", + ] { + assert!(parse_proppatch_request(xml).is_err()); + assert!(parse_lock_request(xml).is_err()); + assert!(parse_report_root(xml).is_err()); + } +} + +#[test] +fn safety_validation_precedes_unknown_subtree_extensibility() { + for xml in [ + br#"]>&x;"#.as_slice(), + br#""#, + ] { + assert_eq!( + parse_propfind_request(xml), + Err(DavXmlError::ExternalEntity) + ); + } +} + +#[test] +fn safety_depth_accepts_exact_limit_and_rejects_one_over_even_when_ignored() { + fn nested(depth: usize) -> Vec { + let mut xml = String::from(""); + for _ in 2..depth { + xml.push_str(""); + } + for _ in 2..depth { + xml.push_str(""); + } + xml.push_str(""); + xml.into_bytes() + } + + assert!(parse_propfind_request(&nested(DEFAULT_XML_MAX_DEPTH)).is_ok()); + assert_eq!( + parse_propfind_request(&nested(DEFAULT_XML_MAX_DEPTH + 1)), + Err(DavXmlError::TooDeep) + ); +} + +#[test] +fn proppatch_preserves_order_qnames_values_and_inherited_language() { + let patches = parse_proppatch_request( + r#" + 蓝色 + + "# + .as_bytes(), + ) + .unwrap(); + assert_eq!(patches.len(), 2); + assert!(patches[0].set); + assert_eq!(patches[0].property.name, "color"); + assert_eq!(patches[0].property.namespace.as_deref(), Some("urn:a")); + assert_eq!( + patches[0].property.element.attributes.get("xml:lang"), + Some(&"zh-CN".to_owned()) + ); + assert_eq!( + patches[0].property.element.text().as_deref(), + Some("蓝色+青") + ); + assert!(!patches[1].set); + assert_eq!( + patches[1].property.element.attributes.get("xml:lang"), + Some(&"en".to_owned()) + ); +} + +#[test] +fn proppatch_does_not_inherit_an_unqualified_lang_attribute() { + let patches = parse_proppatch_request( + br#" + + "#, + ) + .expect("unqualified lang is an ordinary application attribute"); + + assert_eq!(patches.len(), 1); + assert!( + !patches[0] + .property + .element + .attributes + .contains_key("xml:lang") + ); +} + +#[test] +fn proppatch_ignores_unknown_action_subtrees_but_rejects_known_grammar_errors() { + let patches = parse_proppatch_request( + br#" + + + "#, + ) + .unwrap(); + assert_eq!(patches.len(), 1); + assert_eq!(patches[0].property.name, "active"); + + for xml in [ + br#""#.as_slice(), + br#""#, + br#""#, + br#""#, + ] { + assert_eq!( + parse_proppatch_request(xml), + Err(DavXmlError::InvalidGrammar) + ); + } +} + +#[test] +fn lock_parses_exclusive_shared_owner_and_extensions() { + for (scope, shared) in [("exclusive", false), ("shared", true)] { + let xml = format!( + r#" + + + + 用户 & owner + "# + ); + let request = parse_lock_request(xml.as_bytes()).unwrap(); + assert_eq!(request.shared, shared); + let owner = request.owner.unwrap(); + assert_eq!(owner.name, "owner"); + assert!( + owner + .to_bytes() + .unwrap() + .windows(4) + .any(|part| part == b"&") + ); + } +} + +#[test] +fn lock_rejects_qname_collisions_missing_controls_and_duplicates() { + for xml in [ + br#""#.as_slice(), + br#""#, + br#""#, + br#""#, + br#""#, + br#""#, + ] { + assert_eq!(parse_lock_request(xml), Err(DavXmlError::InvalidGrammar)); + } +} + +#[test] +fn report_root_is_qname_aware() { + let root = parse_report_root(br#""#) + .unwrap(); + assert_eq!(root.name, "version-tree"); + assert_eq!(root.namespace.as_deref(), Some("DAV:")); + assert_eq!(root.prefix.as_deref(), Some("D")); + + let collision = parse_report_root(br#""#).unwrap(); + assert_eq!(collision.namespace.as_deref(), Some("urn:x")); +} + +#[test] +fn xml_boundary_round_trips_namespaces_comments_cdata_utf8_and_escaping() { + let original = r#""#; + let element = DavXmlElement::parse(original.as_bytes()).unwrap(); + let bytes = element.to_bytes().unwrap(); + let reparsed = DavXmlElement::parse(&bytes).unwrap(); + assert_eq!(reparsed.name, "color"); + assert_eq!(reparsed.namespace.as_deref(), Some("urn:a")); + assert_eq!(reparsed.text().as_deref(), Some("蓝+青")); + assert_eq!( + reparsed.attributes.get("quote").map(String::as_str), + Some("\" & <") + ); + assert!( + reparsed + .children + .iter() + .any(|node| matches!(node, DavXmlNode::Comment(_))) + ); +} + +#[test] +fn xml_boundary_preserves_default_namespace_undeclaration() { + let original = br#""#; + let element = DavXmlElement::parse(original).unwrap(); + let bytes = element.to_bytes().unwrap(); + let reparsed = DavXmlElement::parse(&bytes).unwrap(); + let plain = reparsed.child_elements().next().unwrap(); + + assert_eq!(plain.name, "plain"); + assert_eq!(plain.namespace, None); +} + +#[test] +fn xml_boundary_round_trips_namespace_shadowing_and_namespaced_attributes() { + let original = br#""#; + let element = DavXmlElement::parse(original).unwrap(); + let bytes = element.to_bytes().unwrap(); + let reparsed = DavXmlElement::parse(&bytes).unwrap(); + let item = reparsed.child_elements().next().unwrap(); + let plain = item.child_elements().next().unwrap(); + let leaf = plain.child_elements().next().unwrap(); + + assert_eq!(reparsed.namespace.as_deref(), Some("urn:root")); + assert_eq!(item.namespace.as_deref(), Some("urn:a")); + assert_eq!(item.attributes.get("a:id").map(String::as_str), Some("7")); + assert_eq!(plain.namespace, None); + assert_eq!(leaf.namespace.as_deref(), Some("urn:b")); +} + +#[test] +fn xml_reader_maps_io_invalid_encoding_and_size_boundaries() { + assert_eq!( + DavXmlElement::parse_reader(FailingReader), + Err(DavXmlError::Malformed) + ); + assert_eq!( + DavXmlElement::parse_reader(Cursor::new(b"\xff")), + Err(DavXmlError::Malformed) + ); + assert_eq!( + DavXmlElement::parse_reader(Cursor::new(b"")), + Err(DavXmlError::Malformed) + ); + + let max_input_bytes = XmlSafetyPolicy::untrusted().max_input_bytes; + let mut exact = Vec::with_capacity(max_input_bytes); + exact.extend_from_slice(b""); + exact.resize(max_input_bytes - b"".len(), b'x'); + exact.extend_from_slice(b""); + assert_eq!(exact.len(), max_input_bytes); + assert!(DavXmlElement::parse_reader(Cursor::new(&exact)).is_ok()); + + exact.insert(b"".len(), b'x'); + assert_eq!(exact.len(), max_input_bytes + 1); + assert_eq!( + DavXmlElement::parse_reader(Cursor::new(&exact)), + Err(DavXmlError::TooLarge) + ); +} + +#[test] +fn xml_writer_rejects_invalid_models_and_conflicting_namespaces() { + let unbound_prefix = DavXmlElement::new("p:root"); + assert_eq!(unbound_prefix.to_bytes(), Err(DavXmlError::Malformed)); + + let mut conflicting_namespace = DavXmlElement::dav("root"); + conflicting_namespace + .attributes + .insert("xmlns:D".to_owned(), "urn:not-dav".to_owned()); + assert_eq!( + conflicting_namespace.to_bytes(), + Err(DavXmlError::Malformed) + ); + + for node in [ + DavXmlNode::Text("bad\u{0}text".to_owned()), + DavXmlNode::CData("bad]]>cdata".to_owned()), + DavXmlNode::Comment("bad--comment".to_owned()), + DavXmlNode::ProcessingInstruction("xml".to_owned(), None), + DavXmlNode::ProcessingInstruction("work".to_owned(), Some("bad?>pi".to_owned())), + ] { + let mut element = DavXmlElement::dav("root"); + element.children.push(node); + assert_eq!(element.to_bytes(), Err(DavXmlError::Malformed)); + } +} + +#[test] +fn xml_writer_accepts_exact_depth_and_rejects_one_over() { + fn nested(depth: usize) -> DavXmlElement { + let mut element = DavXmlElement::dav("node"); + for _ in 1..depth { + let mut parent = DavXmlElement::dav("node"); + parent.children.push(DavXmlNode::Element(element)); + element = parent; + } + element + } + + assert!(nested(DEFAULT_XML_MAX_DEPTH).to_bytes().is_ok()); + assert_eq!( + nested(DEFAULT_XML_MAX_DEPTH + 1).to_bytes(), + Err(DavXmlError::TooDeep) + ); +} + +#[test] +fn xml_writer_escapes_text_and_attributes() { + let mut element = DavXmlElement::dav("href"); + element.namespaces.insert("D".to_owned(), "DAV:".to_owned()); + element + .attributes + .insert("data".to_owned(), "\"<&".to_owned()); + element + .children + .push(DavXmlNode::Text("/猫猫?a=1&b=".to_owned())); + let bytes = element.to_bytes().unwrap(); + let xml = String::from_utf8(bytes).unwrap(); + assert!(xml.contains("data=\""<&\""), "{xml}"); + assert!(xml.contains("/猫猫?a=1&b=<x>"), "{xml}"); +} + +#[test] +fn large_bounded_property_payload_round_trips() { + let payload = "猫&".repeat(32_768); + let escaped = payload + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + let xml = format!(r#"{escaped}"#); + let element = DavXmlElement::parse(xml.as_bytes()).unwrap(); + assert_eq!(element.text().as_deref(), Some(payload.as_str())); + let reparsed = DavXmlElement::parse(&element.to_bytes().unwrap()).unwrap(); + assert_eq!(reparsed.text().as_deref(), Some(payload.as_str())); +} diff --git a/crates/aster_forge_webdav/tests/xml_response.rs b/crates/aster_forge_webdav/tests/xml_response.rs new file mode 100644 index 0000000..6aecb3b --- /dev/null +++ b/crates/aster_forge_webdav/tests/xml_response.rs @@ -0,0 +1,290 @@ +use std::time::Duration; + +use aster_forge_webdav::{ + DavErrorCondition, DavLockXml, DavMultiStatusItem, DavPropStat, DavVersionXml, DavXmlElement, + DavXmlNode, dav_dead_property_element, dav_element, dav_error_element, + dav_lock_discovery_element, dav_lock_response_element, dav_multistatus_element, + dav_property_child_element, dav_property_name_element, dav_property_text_element, + dav_supported_lock_element, dav_version_multistatus_element, +}; +use http::StatusCode; + +fn xml(element: &DavXmlElement) -> String { + String::from_utf8(element.to_bytes().unwrap()).unwrap() +} + +#[test] +fn multistatus_preserves_response_propstat_and_property_order() { + let root = dav_multistatus_element(vec![ + DavMultiStatusItem::properties( + "/webdav/a%20b.txt", + vec![ + DavPropStat { + status: StatusCode::OK.as_u16(), + properties: vec![dav_element("displayname"), dav_element("getetag")], + }, + DavPropStat { + status: StatusCode::NOT_FOUND.as_u16(), + properties: vec![dav_element("quota-used-bytes")], + }, + ], + ), + DavMultiStatusItem::status("/webdav/locked", StatusCode::LOCKED.as_u16()).with_error( + DavErrorCondition::LockTokenSubmitted { + href: "/webdav/locked".to_owned(), + }, + ), + ]); + let output = xml(&root); + assert!(output.contains("xmlns:D=\"DAV:\""), "{output}"); + assert!(output.contains("HTTP/1.1 200 OK"), "{output}"); + assert!(output.contains("HTTP/1.1 404 Not Found"), "{output}"); + assert!(output.contains("HTTP/1.1 423 Locked"), "{output}"); + assert!(output.contains("lock-token-submitted"), "{output}"); + assert!( + output.find("displayname").unwrap() < output.find("getetag").unwrap(), + "{output}" + ); + DavXmlElement::parse(output.as_bytes()).unwrap(); +} + +#[test] +fn error_documents_cover_every_owned_condition_and_escape_hrefs() { + let cases = [ + ( + DavErrorCondition::NoExternalEntities, + "no-external-entities", + ), + ( + DavErrorCondition::LockTokenSubmitted { + href: "/webdav/a?x=1&y=<2>".to_owned(), + }, + "lock-token-submitted", + ), + ( + DavErrorCondition::LockTokenMatchesRequestUri, + "lock-token-matches-request-uri", + ), + ( + DavErrorCondition::PropfindFiniteDepth, + "propfind-finite-depth", + ), + ]; + for (condition, expected) in cases { + let output = xml(&dav_error_element(&condition)); + assert!(output.contains(expected), "{output}"); + assert!(output.contains("xmlns:D=\"DAV:\""), "{output}"); + if matches!(condition, DavErrorCondition::LockTokenSubmitted { .. }) { + assert!(output.contains("&"), "{output}"); + assert!(output.contains("<2>"), "{output}"); + } + DavXmlElement::parse(output.as_bytes()).unwrap(); + } +} + +#[test] +fn supportedlock_has_exclusive_and_shared_write_entries() { + let output = xml(&dav_supported_lock_element()); + assert_eq!(output.matches("").count(), 2, "{output}"); + assert!(output.contains(">(); + assert_eq!(active_locks.len(), 2); + assert_eq!( + active_locks[0] + .child_elements() + .map(|element| element.name.as_str()) + .collect::>(), + [ + "lockscope", + "locktype", + "depth", + "owner", + "timeout", + "locktoken", + "lockroot", + ] + ); + assert_eq!( + active_locks[1] + .child_elements() + .map(|element| element.name.as_str()) + .collect::>(), + [ + "lockscope", + "locktype", + "depth", + "timeout", + "locktoken", + "lockroot", + ] + ); + + let response = xml(&dav_lock_response_element(&locks)); + assert!(response.contains("xmlns:D=\"DAV:\""), "{response}"); + DavXmlElement::parse(response.as_bytes()).unwrap(); +} + +#[test] +fn deltav_multistatus_escapes_values_and_keeps_protocol_property_order() { + let root = dav_version_multistatus_element(vec![DavVersionXml { + href: "/webdav/a?v=1&kind=".to_owned(), + version_name: "V1".to_owned(), + creator: "猫 & owner".to_owned(), + content_length: 42, + last_modified: "Thu, 01 Jan 1970 00:00:00 GMT".to_owned(), + }]); + let output = xml(&root); + assert!(output.contains("?v=1&kind=<old>"), "{output}"); + assert!(output.contains("猫 & owner"), "{output}"); + let names = [ + "version-name", + "creator-displayname", + "getcontentlength", + "getlastmodified", + ]; + for pair in names.windows(2) { + assert!( + output.find(pair[0]).unwrap() < output.find(pair[1]).unwrap(), + "{output}" + ); + } + DavXmlElement::parse(output.as_bytes()).unwrap(); +} + +#[test] +fn property_builders_preserve_qnames_values_and_namespace_declarations() { + let dav = aster_forge_webdav::DavRequestedProperty { + name: "getetag".to_owned(), + namespace: Some("DAV:".to_owned()), + prefix: Some("Z".to_owned()), + }; + let custom = aster_forge_webdav::DavRequestedProperty { + name: "color".to_owned(), + namespace: Some("urn:custom".to_owned()), + prefix: None, + }; + let plain = aster_forge_webdav::DavRequestedProperty { + name: "plain".to_owned(), + namespace: None, + prefix: None, + }; + let dav_xml = xml(&dav_property_text_element(&dav, "\"etag&1\"")); + assert!(dav_xml.contains("标题粗体"# + .as_bytes(), + ), + ); + let valid_xml = xml(&valid); + assert!(valid_xml.contains(""), + )); + assert!(invalid.contains("<broken & value>"), "{invalid}"); + DavXmlElement::parse(invalid.as_bytes()).unwrap(); +} diff --git a/crates/aster_forge_xml/src/document.rs b/crates/aster_forge_xml/src/document.rs index 926f965..7deed5b 100644 --- a/crates/aster_forge_xml/src/document.rs +++ b/crates/aster_forge_xml/src/document.rs @@ -13,8 +13,8 @@ use quick_xml::escape::unescape; use quick_xml::events::{BytesStart, Event}; use crate::syntax::{ - XML_NAMESPACE_URI, map_quick_xml_error, split_qualified_name, utf8, validate_namespace_binding, - validate_qualified_name, + XML_NAMESPACE_URI, map_quick_xml_error_at, split_qualified_name, utf8, + validate_namespace_binding, validate_qualified_name, }; use crate::{Error, ParseOptions, XmlSafetyError, XmlSafetyPolicy}; @@ -713,7 +713,16 @@ impl<'a> DocumentBuilder<'a> { reader.config_mut().check_end_names = true; loop { let event_start = reader.buffer_position(); - let event = reader.read_event().map_err(map_quick_xml_error)?; + let event = reader.read_event().map_err(|error| { + let error_position = + usize::try_from(reader.error_position()).unwrap_or(self.source.len()); + map_quick_xml_error_at( + error, + error_position, + self.source, + self.options.safety.reject_doctype, + ) + })?; let event_end = reader.buffer_position(); if !matches!(event, Event::Eof) { self.count_event()?; diff --git a/crates/aster_forge_xml/src/parser.rs b/crates/aster_forge_xml/src/parser.rs index c50b11d..6aafd5f 100644 --- a/crates/aster_forge_xml/src/parser.rs +++ b/crates/aster_forge_xml/src/parser.rs @@ -8,8 +8,8 @@ use quick_xml::escape::unescape; use quick_xml::events::{BytesStart, Event}; use crate::syntax::{ - XML_NAMESPACE_URI, map_quick_xml_error, split_qualified_name, utf8, validate_namespace_binding, - validate_qualified_name, + XML_NAMESPACE_URI, map_quick_xml_error_at, split_qualified_name, utf8, + validate_namespace_binding, validate_qualified_name, }; use crate::{DEFAULT_XML_MAX_DEPTH, Error, XmlSafetyError}; @@ -240,7 +240,10 @@ fn scan_xml(bytes: &[u8], options: &ParseOptions) -> Result, Erro let mut state = ScanState::default(); loop { - let event = reader.read_event().map_err(map_quick_xml_error)?; + let event = reader.read_event().map_err(|error| { + let error_position = usize::try_from(reader.error_position()).unwrap_or(bytes.len()); + map_quick_xml_error_at(error, error_position, bytes, options.safety.reject_doctype) + })?; if !matches!(event, Event::Eof) { state.count_event(options.safety)?; } diff --git a/crates/aster_forge_xml/src/stream.rs b/crates/aster_forge_xml/src/stream.rs index 6c7c71e..35425e3 100644 --- a/crates/aster_forge_xml/src/stream.rs +++ b/crates/aster_forge_xml/src/stream.rs @@ -2,7 +2,6 @@ use std::borrow::Cow; use std::io::{BufRead, Take, Write}; -use std::str; use aster_forge_utils::numbers::usize_to_u64; use quick_xml::XmlVersion; @@ -14,6 +13,7 @@ use quick_xml::name::{NamespaceResolver, PrefixDeclaration, ResolveResult}; use quick_xml::reader::NsReader; use quick_xml::writer::Writer; +use crate::syntax::{map_quick_xml_error, utf8}; use crate::{Error, ValidatedXml, XmlSafetyError, XmlSafetyPolicy}; /// A namespace-resolved XML name borrowed from one streaming event. @@ -715,17 +715,6 @@ fn decode_reference<'a>( })) } -fn utf8(bytes: &[u8]) -> Result<&str, Error> { - str::from_utf8(bytes).map_err(|_| XmlSafetyError::InvalidEncoding.into()) -} - -fn map_quick_xml_error(error: quick_xml::Error) -> Error { - match error { - quick_xml::Error::Encoding(_) => XmlSafetyError::InvalidEncoding.into(), - error => Error::InvalidXml(error.to_string()), - } -} - fn write_capture_event(writer: &mut Writer, event: Event<'_>) -> Result<(), Error> { if let Err(error) = writer.write_event(event) { if writer.get_ref().exceeded { diff --git a/crates/aster_forge_xml/src/syntax.rs b/crates/aster_forge_xml/src/syntax.rs index 30e6ef6..4ae6aa4 100644 --- a/crates/aster_forge_xml/src/syntax.rs +++ b/crates/aster_forge_xml/src/syntax.rs @@ -80,3 +80,27 @@ pub(crate) fn map_quick_xml_error(error: quick_xml::Error) -> Error { error => Error::InvalidXml(error.to_string()), } } + +// External-entity classification relies on quick-xml reporting `InvalidBangMarkup` with +// `error_position()` at the opening ` Error { + if reject_doctype + && matches!( + error, + quick_xml::Error::Syntax(quick_xml::errors::SyntaxError::InvalidBangMarkup) + ) + && input + .get(error_position..) + .is_some_and(|input| input.starts_with(b"]>&x;", policy), Err(XmlSafetyError::ExternalEntity) ); + assert_eq!( + validate_xml_input(b"", policy), + Err(XmlSafetyError::ExternalEntity) + ); assert!(validate_xml_input(b"]]>", policy).is_ok()); + assert_eq!( + validate_xml_input(b"", policy), + Err(XmlSafetyError::Malformed) + ); +} + +#[test] +fn classifies_entity_declarations_consistently_across_complete_document_entry_points() { + let policy = XmlSafetyPolicy::untrusted(); + let options = ParseOptions::new().safety_policy(policy); + let entity = b""; + + assert_eq!( + validate_xml_input(entity, policy), + Err(XmlSafetyError::ExternalEntity) + ); + assert!(matches!( + BorrowedDocument::parse_with_options(entity.as_slice(), &options), + Err(Error::Safety(XmlSafetyError::ExternalEntity)) + )); + assert!(matches!( + OwnedDocument::from_reader_with_options(Cursor::new(entity), &options), + Err(Error::Safety(XmlSafetyError::ExternalEntity)) + )); + + for harmless in [ + b"".as_slice(), + b"]]>", + ] { + assert!(validate_xml_input(harmless, policy).is_ok()); + assert!(BorrowedDocument::parse_with_options(harmless, &options).is_ok()); + assert!(OwnedDocument::from_reader_with_options(Cursor::new(harmless), &options).is_ok()); + } + + let malformed = b""; + assert_eq!( + validate_xml_input(malformed, policy), + Err(XmlSafetyError::Malformed) + ); + assert!(matches!( + BorrowedDocument::parse_with_options(malformed.as_slice(), &options), + Err(Error::InvalidXml(_)) + )); + assert!(matches!( + OwnedDocument::from_reader_with_options(Cursor::new(malformed), &options), + Err(Error::InvalidXml(_)) + )); } #[test] diff --git a/docs/crates/aster_forge_utils.md b/docs/crates/aster_forge_utils.md index 183ea6f..be461fb 100644 --- a/docs/crates/aster_forge_utils.md +++ b/docs/crates/aster_forge_utils.md @@ -8,6 +8,7 @@ - Gravatar hash 和 URL 拼接。 - best-effort 临时文件和目录清理。 - HTML 和 inline script 占位符 escaping。 +- 单段 HTTP byte range 解析与 representation-relative 区间计算。 - HTTP date 和条件请求 ETag 比较。 - UUID 和 token 生成。 - 网络地址和 trusted proxy 解析。 @@ -114,6 +115,19 @@ Spotlight/Finder 或文件监听器在删除过程中短暂写入目录的情况 唯一 UUID 生成流程通过 `UniqueUuidAttempt` 把“候选冲突”和“成功结果”表达出来。产品侧决定冲突如何查询数据库。 +### http_range + +主要 API: + +- `parse_single_byte_range(raw, total_size)` +- `HttpByteRange` +- `HttpRangeError` + +该模块只处理 transport-neutral 的单段 `bytes=` range:支持 bounded、open-ended 和 suffix +形式,按 representation 长度截断越界 end,并明确区分非法 unit、多段 range、数字错误、空 +representation 和 unsatisfiable range。它不决定 HTTP 状态码,也不打开 storage stream;REST、 +WebDAV 或内部传输协议应在自己的响应边界映射错误和选择读取方式。 + ### http_validators 主要 API: @@ -210,6 +224,8 @@ Unicode segmentation 策略。 - 每个产品接入点覆盖非法输入。 - 数值转换测试要包含负数、超上限和边界值。 - URL/origin 测试要覆盖 loopback HTTP、HTTPS、wildcard。 +- HTTP range 测试要覆盖 bounded/open-ended/suffix、end 截断、空文件、零 suffix、多段请求、 + `u64` 上界和 unsatisfiable offset。 - trusted proxy 测试要覆盖多代理链。 ## 参考项目 diff --git a/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md new file mode 100644 index 0000000..e7014b9 --- /dev/null +++ b/docs/crates/aster_forge_webdav.md @@ -0,0 +1,78 @@ +# aster_forge_webdav + +`aster_forge_webdav` 是 Aster 产品共用的 WebDAV 协议边界。它负责把 HTTP/WebDAV 输入解析为强类型请求,校验路径、`Depth`、`Destination`、`If`、ETag 和日期条件,并定义协议响应、产品 backend port 与操作事件。 + +这个 crate 不拥有 AsterDrive 的文件业务。认证账号、workspace scope、权限、SeaORM entity、存储策略、quota、版本落库和审计展示仍然留在产品仓库。 + +## Cargo 接入 + +```toml +[dependencies] +aster_forge_webdav = { git = "https://github.com/AsterCommunity/AsterForge", features = ["actix"] } +``` + +默认 feature 只包含 transport-neutral 协议内核。Actix 产品启用 `actix`,使用 `aster_forge_webdav::actix` 完成请求和响应类型转换。 + +## 协议所有权 + +Forge 负责: + +- `DavPath` 的百分号解码、dot-segment 规范化和 mount escape 拒绝。 +- WebDAV 方法、`Depth`、`Overwrite`、`Destination`、`If`、`Timeout` 和 `Lock-Token` header 解析。 +- `If` tagged-resource 归一化、AND/OR/Not 状态机,以及只暴露 ETag/lock token 的 resolver port。 +- 通过 `DavFileSystem` / `DavLockSystem` 统一执行 `If`、资源锁、父级锁、父集合存在性与 LOCK lock-null 文件前置条件;产品不再复制 resolver/guard。 +- LOCK acquire/refresh 选择、timeout/token/body 校验与成功响应 composition。 +- COPY/MOVE/DELETE 的资源路径关系、typed partial failure、207 与 201/204 响应选择。 +- 每个 DAV 方法的 empty/bounded XML/stream/unused body policy,以及 Actix bounded-body adapter。 +- request head 保留规范化后的请求 origin;Actix adapter 按方法一次性完成 empty/XML/stream body preparation。 +- HTTP ETag、`If-Modified-Since`、`If-Unmodified-Since` 的协议优先级。 +- GET/HEAD 的 200/206/304/416 response planning、单段 byte range 选择与读取区间。 +- PUT 的 `If-Match` / `If-None-Match` 前置条件、`create` / `create_new` 选择、`X-Expected-Entity-Length` 优先级、collection target 405 拒绝和 201/204 成功响应选择。 +- `DavRequestHead`、`DavResponse`、`DavEvent` 等协议模型。 +- `DavEvent::completed` 从 request head 生成脱敏完成事件,不携带 `If` token、凭据或正文。 +- PROPFIND、PROPPATCH、LOCK、REPORT 的 XML 安全校验、QName 语法和未知扩展处理。 +- PROPFIND 的 allprop/include/propname/prop selector、去重和 200/404 propstat 分组。 +- PROPPATCH 的状态分组、PROPFIND/PROPPATCH XML error mapping、finite-depth 与 207 response composition。 +- DeltaV `DAV:version-tree` REPORT 选择、file-only/unsupported mapping、version multistatus 和 VERSION-CONTROL response selection。 +- 已知 request grammar 直接遍历 `aster_forge_xml` 的 source-backed arena,不先重复 validation,也不复制整棵通用 DOM;只有需要持久化或回显的 owner/property 子树才物化为 `DavXmlElement`。 +- `DavXmlElement` 只承担 DAV 持久化子树与 response composition;通用解析、安全限制、namespace 和 reader/writer 由 `aster_forge_xml` 承担。 +- DAV error、multistatus/propstat、dead property、supportedlock/lockdiscovery 和 DeltaV version-tree 的 response grammar。 +- 唯一 backend contract:`DavFileSystem`、`DavMetaData`、`DavFile`、`DavDirEntry`、 + `DavLockSystem`、`FsError` 和 `OpenOptions`;产品只实现这些 Forge port,不再复制协议 trait。 +- 批量 dead-property 读取只向 backend 传递 `DavPath`;产品 adapter 自行解析数据库身份并执行批量查询。 +- Actix transport 与 transport-neutral `http` 类型的显式转换。 +- Actix adapter 统一完成 header conversion、协议/后端错误响应和 HTTP ETag/`If` guard 映射。 +- OPTIONS、405、body-policy failure 和 download response 的 product-neutral response shell。 + +产品负责: + +- Basic/WebDAV account 认证与限流。 +- principal、个人/团队 workspace scope 和 permission guard。 +- 文件、目录、blob、quota、storage policy 和版本业务事务。 +- dead property 和 lock 的具体持久化。 +- 产品 audit action/detail、metrics label 和用户通知。 + +## Backend 与事件 + +产品应把已认证、已限定 workspace 的 adapter 交给协议层。backend 调用必须同步完成影响协议正确性的操作;quota、blob 引用、lock 持久化和必要的缓存失效不能依赖事件补写。 + +`DavEventSink` 只观察已经完成的协议操作,适合 tracing、metrics、审计适配和通知。事件使用 transport-neutral `u16` 状态码,不包含请求正文、凭据或 lock token。 + +## 错误边界 + +- 协议输入错误使用 `DavProtocolError`,由 transport adapter 映射为 WebDAV 状态码和响应。 +- 产品 adapter 把业务错误压缩为 `DavBackendErrorKind`;详细错误和产品文案留在产品日志与 API 边界。 +- Forge 不直接返回 AsterDrive 的 envelope,也不依赖产品错误类型。 + +## 测试要求 + +- 协议 crate 测试路径逃逸、header grammar、同源 `Destination`、条件请求和 request-head 解析。 +- fake backend 矩阵覆盖 ETag + lock token 联合解析、tagged lock root、父级锁、父集合、lock-null 文件创建,以及 metadata/open/flush 错误传播。 +- XML 边界矩阵覆盖空体、QName 冲突、未知子树、重复/互斥控制、DTD/ENTITY、reader I/O、输入大小与深度精确临界、非法 UTF-8、转义和大属性值。 +- XML response 矩阵覆盖状态行、元素顺序、QName、namespace shadowing/undeclaration、锁字段、死属性重建、异常旧值转义,以及非法 writer model 与深度临界。 +- 产品仓库保留真实认证、数据库、存储、quota、audit 和客户端集成测试。 +- Litmus、rclone、curl、cadaver 兼容测试仍应针对具体产品 server 运行,因为它们验证的是协议层和产品 adapter 的组合结果。 + +## 参考项目 + +- AsterDrive:`src/webdav/` 保留产品 adapter;`tests/webdav/` 和 WebDAV compatibility workflow 验证完整产品行为。 diff --git a/docs/guide/new-project-integration.md b/docs/guide/new-project-integration.md index fff36ac..1f7a573 100644 --- a/docs/guide/new-project-integration.md +++ b/docs/guide/new-project-integration.md @@ -83,6 +83,7 @@ aster_forge_runtime = { git = "https://github.com/AsterCommunity/AsterForge", pa aster_forge_tasks = { git = "https://github.com/AsterCommunity/AsterForge", package = "aster_forge_tasks", features = ["runtime-component"] } aster_forge_utils = { git = "https://github.com/AsterCommunity/AsterForge", package = "aster_forge_utils" } aster_forge_validation = { git = "https://github.com/AsterCommunity/AsterForge", package = "aster_forge_validation" } +aster_forge_webdav = { git = "https://github.com/AsterCommunity/AsterForge", package = "aster_forge_webdav", features = ["actix"] } aster_forge_xml = { git = "https://github.com/AsterCommunity/AsterForge", package = "aster_forge_xml" } ``` @@ -114,6 +115,7 @@ Feature 边界要保持显式。默认 feature 只应该带最小可用内核, | `aster_forge_mail` | 无 | `persistence`, `runtime-component`, `openapi` | sender/template 默认可用;SeaORM outbox model 和 runtime drain component 分开启用。 | | `aster_forge_metrics` | 无 | `backend-prometheus`, `runtime-health`, `allocator-metrics` | backend 由产品入口统一选择;普通产品用 `init_configured_or_noop()`。 | | `aster_forge_tasks` | 无 | `runtime`, `runtime-component`, `openapi` | retry、dedupe、steps、spec 默认可用;worker/scheduled runtime 和 component factory 分开启用。 | +| `aster_forge_webdav` | 无 | `actix` | transport-neutral 协议内核默认可用;Actix adapter 显式启用。 | ## main.rs 目标形态 diff --git a/docs/index.md b/docs/index.md index 2ba66c8..554b057 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,6 +64,7 @@ aster_forge_runtime::AsterRuntime::builder() - [`aster_forge_tasks`](./crates/aster_forge_tasks.md) - [`aster_forge_utils`](./crates/aster_forge_utils.md) - [`aster_forge_validation`](./crates/aster_forge_validation.md) +- [`aster_forge_webdav`](./crates/aster_forge_webdav.md) - [`aster_forge_xml`](./crates/aster_forge_xml.md) ## 参考项目