From b2363e9bb9512350cfb4ca6acf80bd24b0c02391 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 24 Jul 2026 20:42:28 +0800 Subject: [PATCH 01/11] feat(aster_forge_webdav): add product-neutral WebDAV protocol engine ## Summary Introduce `aster_forge_webdav`, a transport-neutral WebDAV protocol crate that handles RFC 4918 compliance, path canonicalization, header parsing, and protocol preconditions. This crate defines backend ports for resource operations, property storage, lock management, and optional versioning, allowing product repositories to own authentication, authorization, workspace scope, and persistence semantics. ## Features - **Path handling**: Canonical `DavPath` with percent-decoding, dot-segment normalization, and mount-escape rejection - **Request parsing**: Transport-neutral `DavRequestHead` with method-specific `Depth`, `Overwrite`, `Destination`, and `If` header validation - **Precondition evaluation**: HTTP ETag and modification-date rules following RFC 7232 precedence - **Backend ports**: `DavResourceBackend`, `DavPropertyBackend`, `DavLockBackend`, and optional `DavVersionBackend` traits for product adapters - **Event observation**: `DavEventSink` for non-authoritative audit, metrics, and notifications after synchronous operations complete - **Actix transport**: Optional feature-gated adapter converting Actix types to/from protocol-neutral `http` 1.x structures - **Error boundaries**: Stable `DavProtocolError` and `DavBackendErrorKind` classifications isolating protocol from product concerns ## Implementation details - `path.rs`: Percent-encoding, dot-segment cleaning, collection trailing-slash semantics, and parent/child path helpers - `protocol.rs`: `Depth`, `Destination`, `If` header grammar, same-origin validation, and HTTP conditional request evaluation - `request.rs`: `DavMethod` and `DavRequestHead` parsing with mount-relative target extraction - `response.rs`: Transport-neutral `DavResponse` with empty, byte, or stream body variants - `backend.rs`: Trait definitions for resource CRUD, dead properties, locks, and optional DeltaV version control - `event.rs`: Observable operation events excluding credentials, bodies, and tokens - `actix.rs`: Actix-web integration converting between framework types and protocol models - Comprehensive protocol tests covering path escapes, header grammar, same-origin constraints, and precondition rules ## Documentation - `docs/crates/aster_forge_webdav.md`: Chinese design doc explaining protocol ownership, backend contracts, error boundaries, and testing requirements - Integration guidance in `docs/guide/new-project-integration.md` - Updated root README and docs index ## Dependencies - `bytes`, `percent-encoding`, `urlencoding`: Path and content handling - `async-trait`, `futures`, `http`, `thiserror`: Core protocol infrastructure - Optional `actix-web` behind `actix` feature for transport adaptation - Internal `aster_forge_utils` for HTTP validator helpers --- Cargo.lock | 21 + Cargo.toml | 4 + README.md | 2 +- README.zh.md | 2 +- crates/aster_forge_webdav/Cargo.toml | 24 + crates/aster_forge_webdav/src/actix.rs | 68 +++ crates/aster_forge_webdav/src/backend.rs | 228 ++++++++ crates/aster_forge_webdav/src/event.rs | 63 ++ crates/aster_forge_webdav/src/lib.rs | 47 ++ crates/aster_forge_webdav/src/path.rs | 209 +++++++ crates/aster_forge_webdav/src/protocol.rs | 617 ++++++++++++++++++++ crates/aster_forge_webdav/src/request.rs | 158 +++++ crates/aster_forge_webdav/src/response.rs | 42 ++ crates/aster_forge_webdav/tests/protocol.rs | 388 ++++++++++++ docs/crates/aster_forge_webdav.md | 56 ++ docs/guide/new-project-integration.md | 1 + docs/index.md | 1 + 17 files changed, 1929 insertions(+), 2 deletions(-) create mode 100644 crates/aster_forge_webdav/Cargo.toml create mode 100644 crates/aster_forge_webdav/src/actix.rs create mode 100644 crates/aster_forge_webdav/src/backend.rs create mode 100644 crates/aster_forge_webdav/src/event.rs create mode 100644 crates/aster_forge_webdav/src/lib.rs create mode 100644 crates/aster_forge_webdav/src/path.rs create mode 100644 crates/aster_forge_webdav/src/protocol.rs create mode 100644 crates/aster_forge_webdav/src/request.rs create mode 100644 crates/aster_forge_webdav/src/response.rs create mode 100644 crates/aster_forge_webdav/tests/protocol.rs create mode 100644 docs/crates/aster_forge_webdav.md diff --git a/Cargo.lock b/Cargo.lock index 905a106..1a8ad10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -823,6 +823,21 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "aster_forge_webdav" +version = "0.1.0" +dependencies = [ + "actix-web", + "aster_forge_utils", + "async-trait", + "bytes", + "futures", + "http 1.4.2", + "percent-encoding", + "thiserror 2.0.19", + "urlencoding", +] + [[package]] name = "astral-tokio-tar" version = "0.6.4" @@ -5842,6 +5857,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8-zero" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 972034e..41700fe 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", ] resolver = "3" @@ -45,6 +46,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" @@ -58,6 +60,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" @@ -83,4 +86,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] tikv-jemalloc-ctl = "0.7" utoipa = { version = "5.5.0", features = ["chrono", "actix_extras", "repr"] } url = "2" +urlencoding = "2" uuid = { version = "1", features = ["v4"] } diff --git a/README.md b/README.md index 2382e81..b20159d 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) | 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_webdav/Cargo.toml b/crates/aster_forge_webdav/Cargo.toml new file mode 100644 index 0000000..d049982 --- /dev/null +++ b/crates/aster_forge_webdav/Cargo.toml @@ -0,0 +1,24 @@ +[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" } +async-trait.workspace = true +bytes.workspace = true +futures.workspace = true +http.workspace = true +percent-encoding.workspace = true +thiserror.workspace = true +urlencoding.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..34ac025 --- /dev/null +++ b/crates/aster_forge_webdav/src/actix.rs @@ -0,0 +1,68 @@ +//! 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::{DavMethod, DavRequestHead, DavRequestOrigin, DavResponse, DavResponseBody}; + +/// 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) + } + } +} + +/// 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) +} diff --git a/crates/aster_forge_webdav/src/backend.rs b/crates/aster_forge_webdav/src/backend.rs new file mode 100644 index 0000000..0657d95 --- /dev/null +++ b/crates/aster_forge_webdav/src/backend.rs @@ -0,0 +1,228 @@ +//! Product adapter ports consumed by a WebDAV protocol engine. + +use std::pin::Pin; +use std::time::{Duration, SystemTime}; + +use async_trait::async_trait; +use bytes::Bytes; +use futures::Stream; + +use crate::{DavPath, Depth}; + +/// Stream used for product-independent WebDAV content transfer. +pub type DavContentStream = + Pin> + Send + 'static>>; + +/// Stable backend failure categories that the protocol layer can map to WebDAV responses. +#[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 { + /// Stable failure category. Product details stay in product logs and errors. + pub kind: DavBackendErrorKind, +} + +impl DavBackendError { + /// Creates a classified backend error. + #[must_use] + pub const fn new(kind: DavBackendErrorKind) -> Self { + Self { kind } + } +} + +/// WebDAV resource type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavResourceKind { + File, + Collection, +} + +/// Protocol-visible resource metadata supplied by the product adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavResourceMetadata { + pub kind: DavResourceKind, + pub content_length: u64, + pub content_type: Option, + pub etag: Option, + pub created_at: Option, + pub modified_at: Option, +} + +/// One child returned by a collection listing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavDirectoryEntry { + pub path: DavPath, + pub metadata: DavResourceMetadata, +} + +/// Result of opening resource content for a WebDAV response. +pub struct DavReadOutcome { + pub metadata: DavResourceMetadata, + pub content: DavContentStream, +} + +/// Parameters for a WebDAV `PUT` operation. +pub struct DavWriteRequest { + pub path: DavPath, + pub content_length: Option, + pub content_type: Option, + pub checksum: Option, + pub overwrite: bool, + pub content: DavContentStream, +} + +/// Result of a successful `PUT` operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavWriteOutcome { + pub created: bool, + pub metadata: DavResourceMetadata, +} + +/// Resource operations that remain authoritative in the product adapter. +#[async_trait] +pub trait DavResourceBackend: Send + Sync { + async fn metadata(&self, path: &DavPath) -> Result; + async fn list( + &self, + path: &DavPath, + depth: Depth, + ) -> Result, DavBackendError>; + async fn read(&self, path: &DavPath) -> Result; + async fn write(&self, request: DavWriteRequest) -> Result; + async fn create_collection(&self, path: &DavPath) -> Result<(), DavBackendError>; + async fn delete(&self, path: &DavPath, depth: Depth) -> Result<(), DavBackendError>; + async fn copy( + &self, + source: &DavPath, + destination: &DavPath, + depth: Depth, + overwrite: bool, + ) -> Result<(), DavBackendError>; + async fn move_resource( + &self, + source: &DavPath, + destination: &DavPath, + overwrite: bool, + ) -> Result<(), DavBackendError>; + async fn quota(&self) -> Result<(u64, Option), DavBackendError>; +} + +/// Expanded DAV property name. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DavPropertyName { + pub namespace: Option, + pub local_name: String, +} + +/// Stored dead-property value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavProperty { + pub name: DavPropertyName, + pub xml: Option>, +} + +/// One property set/remove mutation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavPropertyPatch { + pub remove: bool, + pub property: DavProperty, +} + +/// Result of one property mutation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavPropertyPatchOutcome { + pub name: DavPropertyName, + pub status: http::StatusCode, +} + +/// Dead-property persistence supplied by the product adapter. +#[async_trait] +pub trait DavPropertyBackend: Send + Sync { + async fn properties( + &self, + path: &DavPath, + include_values: bool, + ) -> Result, DavBackendError>; + async fn patch_properties( + &self, + path: &DavPath, + patches: Vec, + ) -> Result, DavBackendError>; +} + +/// Parameters for acquiring a WebDAV lock. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavLockRequest { + pub path: DavPath, + pub owner_xml: Option>, + pub timeout: Option, + pub shared: bool, + pub deep: bool, +} + +/// Protocol-visible lock state supplied by the product adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavLockInfo { + pub token: String, + pub path: DavPath, + pub owner_xml: Option>, + pub timeout_at: Option, + pub timeout: Option, + pub shared: bool, + pub deep: bool, +} + +/// Lock persistence supplied by the product adapter. +#[async_trait] +pub trait DavLockBackend: Send + Sync { + async fn acquire(&self, request: DavLockRequest) -> Result; + async fn refresh( + &self, + path: &DavPath, + token: &str, + timeout: Option, + ) -> Result; + async fn release(&self, path: &DavPath, token: &str) -> Result<(), DavBackendError>; + async fn discover(&self, path: &DavPath) -> Result, DavBackendError>; + async fn check_write( + &self, + path: &DavPath, + deep: bool, + submitted_tokens: &[String], + ) -> Result<(), DavBackendError>; +} + +/// One protocol-visible resource version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DavVersionInfo { + pub version_id: String, + pub href: String, + pub created_at: Option, + pub etag: Option, +} + +/// Optional DeltaV capability supplied by the product adapter. +#[async_trait] +pub trait DavVersionBackend: Send + Sync { + async fn versions(&self, path: &DavPath) -> Result, DavBackendError>; + async fn enable_version_control(&self, path: &DavPath) -> Result<(), DavBackendError>; +} + +/// Aggregate capability boundary required by a complete WebDAV protocol engine. +pub trait DavBackend: DavResourceBackend + DavPropertyBackend + DavLockBackend {} + +impl DavBackend for T where T: DavResourceBackend + DavPropertyBackend + DavLockBackend {} diff --git a/crates/aster_forge_webdav/src/event.rs b/crates/aster_forge_webdav/src/event.rs new file mode 100644 index 0000000..fff8bb0 --- /dev/null +++ b/crates/aster_forge_webdav/src/event.rs @@ -0,0 +1,63 @@ +//! Observable WebDAV operation events. + +use std::time::Duration; + +use crate::{DavBackendErrorKind, DavPath}; + +/// 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 { + status: http::StatusCode, + }, + Failed { + status: http::StatusCode, + backend_error: Option, + }, +} + +/// 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, +} + +/// 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..579f731 --- /dev/null +++ b/crates/aster_forge_webdav/src/lib.rs @@ -0,0 +1,47 @@ +//! 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 event; +pub mod path; +pub mod protocol; +pub mod request; +pub mod response; + +pub use backend::{ + DavBackend, DavBackendError, DavBackendErrorKind, DavContentStream, DavDirectoryEntry, + DavLockBackend, DavLockInfo, DavLockRequest, DavProperty, DavPropertyBackend, DavPropertyName, + DavPropertyPatch, DavPropertyPatchOutcome, DavReadOutcome, DavResourceBackend, DavResourceKind, + DavResourceMetadata, DavVersionBackend, DavVersionInfo, DavWriteOutcome, DavWriteRequest, +}; +pub use event::{DavEvent, DavEventOutcome, DavEventSink, DavOperation, NoopDavEventSink}; +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 protocol::{ + DavPrecondition, DavProtocolError, DavProtocolErrorKind, Depth, Destination, IfHeader, + IfResourceGroup, IfStateCondition, IfStateList, destination_relative_path, + evaluate_http_download_preconditions, evaluate_http_etag_preconditions, parse_copy_depth, + parse_delete_depth, parse_if_header, parse_lock_depth, parse_move_depth, parse_overwrite, + parse_propfind_depth, submitted_lock_tokens_for_path, +}; +pub use request::{DavMethod, DavRequestHead, DavRequestOrigin}; +pub use response::{DavResponse, DavResponseBody}; diff --git a/crates/aster_forge_webdav/src/path.rs b/crates/aster_forge_webdav/src/path.rs new file mode 100644 index 0000000..5b8bc24 --- /dev/null +++ b/crates/aster_forge_webdav/src/path.rs @@ -0,0 +1,209 @@ +//! Canonical WebDAV path handling. + +use percent_encoding::{AsciiSet, CONTROLS, 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 { + raw: String, + decoded: Vec, +} + +/// Parses a mount-relative request path and returns its canonical decoded representation. +pub fn decode_relative_path(relative: &str) -> Result<(DavPath, String), DavPathError> { + let path = DavPath::new(relative)?; + let decoded = path.as_str().to_string(); + Ok((path, decoded)) +} + +/// 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. +#[must_use] +pub fn child_relative_path(parent: &str, name: &[u8], is_collection: bool) -> String { + let name = String::from_utf8_lossy(name); + 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('/'); + } + 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, +} + +impl DavPath { + /// Percent-decodes and canonicalizes a path without allowing root escape. + pub fn new(path: &str) -> Result { + let raw = ensure_leading_slash(path); + let decoded = urlencoding::decode(&raw) + .map_err(|_| DavPathError::InvalidEncoding)? + .into_owned(); + let raw = clean_decoded_path(&decoded)?; + let decoded = raw.as_bytes().to_vec(); + Ok(Self { raw, decoded }) + } + + /// Returns the WebDAV mount root. + #[must_use] + pub fn root() -> Self { + Self { + raw: "/".to_string(), + decoded: b"/".to_vec(), + } + } + + /// Returns the decoded canonical path bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.decoded + } + + /// Returns the decoded canonical UTF-8 path. + #[must_use] + pub fn as_str(&self) -> &str { + &self.raw + } + + /// Returns whether the path denotes a collection alias. + #[must_use] + pub fn is_collection(&self) -> bool { + self.raw == "/" || self.raw.ends_with('/') + } + + /// Returns the decoded canonical path as a relative mount path. + #[must_use] + pub fn relative(&self) -> &str { + self.as_str() + } +} + +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/protocol.rs b/crates/aster_forge_webdav/src/protocol.rs new file mode 100644 index 0000000..7d67ab2 --- /dev/null +++ b/crates/aster_forge_webdav/src/protocol.rs @@ -0,0 +1,617 @@ +//! WebDAV header parsing and protocol precondition rules. + +use std::time::SystemTime; + +use http::header::{self, HeaderMap, HeaderValue}; +use http::{StatusCode, Uri}; + +use crate::DavPath; +use aster_forge_utils::http_validators; + +/// 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 }, +} + +/// 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 !scheme.eq_ignore_ascii_case(request_scheme) + || !authority.as_str().eq_ignore_ascii_case(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) +} + +/// 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(); + }; + 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 +} + +/// 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 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)) => { + scheme.eq_ignore_ascii_case(request_scheme) + && authority.as_str().eq_ignore_ascii_case(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 = urlencoding::decode(left).ok(); + let right_decoded = urlencoding::decode(right).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/request.rs b/crates/aster_forge_webdav/src/request.rs new file mode 100644 index 0000000..ab2efa3 --- /dev/null +++ b/crates/aster_forge_webdav/src/request.rs @@ -0,0 +1,158 @@ +//! 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, +}; + +/// 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, +} + +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, + } + } +} + +/// 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 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 = uri + .path() + .strip_prefix(mount_path) + .filter(|_| { + mount_path == "/" + || uri.path() == mount_path + || uri + .path() + .as_bytes() + .get(mount_path.len()) + .is_some_and(|byte| *byte == b'/') + }) + .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, + depth, + overwrite, + destination, + if_header: parse_if_header(headers)?, + }) + } +} diff --git a/crates/aster_forge_webdav/src/response.rs b/crates/aster_forge_webdav/src/response.rs new file mode 100644 index 0000000..06d967f --- /dev/null +++ b/crates/aster_forge_webdav/src/response.rs @@ -0,0 +1,42 @@ +//! Transport-neutral WebDAV response model. + +use bytes::Bytes; +use http::{HeaderMap, StatusCode}; + +use crate::DavContentStream; + +/// 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()), + } + } +} diff --git a/crates/aster_forge_webdav/tests/protocol.rs b/crates/aster_forge_webdav/tests/protocol.rs new file mode 100644 index 0000000..acfc2d6 --- /dev/null +++ b/crates/aster_forge_webdav/tests/protocol.rs @@ -0,0 +1,388 @@ +use std::time::{Duration, UNIX_EPOCH}; + +use aster_forge_webdav::{ + DavMethod, DavPath, DavPathError, DavPrecondition, DavRequestHead, DavRequestOrigin, Depth, + IfStateCondition, child_relative_path, destination_relative_path, + 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_move_depth, parse_propfind_depth, submitted_lock_tokens_for_path, +}; +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 +} + +#[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) + )); +} + +#[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), + "/folder/child/" + ); + 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 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"); + + 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 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 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 + ); +} + +#[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.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/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md new file mode 100644 index 0000000..1732bfb --- /dev/null +++ b/docs/crates/aster_forge_webdav.md @@ -0,0 +1,56 @@ +# 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` header 解析。 +- HTTP ETag、`If-Modified-Since`、`If-Unmodified-Since` 的协议优先级。 +- `DavRequestHead`、`DavResponse`、`DavEvent` 等协议模型。 +- `DavResourceBackend`、`DavPropertyBackend`、`DavLockBackend` 和可选 `DavVersionBackend` port。 +- Actix transport 与 transport-neutral `http` 类型的显式转换。 + +产品负责: + +- 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、审计适配和通知。事件不包含请求正文、凭据或 lock token。 + +## 错误边界 + +- 协议输入错误使用 `DavProtocolError`,由 transport adapter 映射为 WebDAV 状态码和响应。 +- 产品 adapter 把业务错误压缩为 `DavBackendErrorKind`;详细错误和产品文案留在产品日志与 API 边界。 +- Forge 不直接返回 AsterDrive 的 envelope,也不依赖产品错误类型。 + +## 测试要求 + +- 协议 crate 测试路径逃逸、header grammar、同源 `Destination`、条件请求和 request-head 解析。 +- 产品仓库保留真实认证、数据库、存储、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 fac5a03..29c108a 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"] } ``` 按需开启 feature: diff --git a/docs/index.md b/docs/index.md index 3b99076..0f037b1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -61,6 +61,7 @@ aster_forge_runtime::AsterRuntime::builder() - [`aster_forge_panic`](./crates/aster_forge_panic.md) - [`aster_forge_runtime`](./crates/aster_forge_runtime.md) - [`aster_forge_storage_core`](./crates/aster_forge_storage_core.md) +- [`aster_forge_webdav`](./crates/aster_forge_webdav.md) - [`aster_forge_tasks`](./crates/aster_forge_tasks.md) - [`aster_forge_utils`](./crates/aster_forge_utils.md) - [`aster_forge_validation`](./crates/aster_forge_validation.md) From 9f2d4bf431877bf2d04b34b8fe92da2e056e6e22 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 24 Jul 2026 21:20:31 +0800 Subject: [PATCH 02/11] feat(aster_forge_webdav): add WebDAV XML grammar boundary with safety validation Implement XML parsing and serialization boundary for PROPFIND, PROPPATCH, LOCK, and REPORT request bodies with QName-aware grammar enforcement and safety validation. Changes: - Add `DavXmlElement` and `DavXmlNode` as transport-independent XML representation boundary - Implement `parse_propfind_request` with allprop/propname/prop selector and include support - Implement `parse_proppatch_request` with ordered set/remove operations and xml:lang inheritance - Implement `parse_lock_request` for exclusive/shared scope and owner element preservation - Implement `parse_report_root` for QName-aware REPORT method dispatch - Add DTD/entity and nesting depth validation via `aster_forge_utils::xml::validate_xml_input` - Update `DavProperty`, `DavLockRequest`, and `DavLockInfo` to use `DavXmlElement` instead of raw bytes - Add comprehensive XML grammar and safety test matrix covering namespace collisions, unknown extensions, escaping, UTF-8, and depth limits - Add dependency: xmltree 0.12 as concrete XML parser (private to xml module) - Update documentation: WebDAV XML responsibility and testing requirements --- Cargo.lock | 16 + Cargo.toml | 1 + crates/aster_forge_webdav/Cargo.toml | 1 + crates/aster_forge_webdav/src/backend.rs | 8 +- crates/aster_forge_webdav/src/lib.rs | 6 + crates/aster_forge_webdav/src/xml.rs | 444 +++++++++++++++++++++++ crates/aster_forge_webdav/tests/xml.rs | 300 +++++++++++++++ docs/crates/aster_forge_webdav.md | 4 +- 8 files changed, 775 insertions(+), 5 deletions(-) create mode 100644 crates/aster_forge_webdav/src/xml.rs create mode 100644 crates/aster_forge_webdav/tests/xml.rs diff --git a/Cargo.lock b/Cargo.lock index 1a8ad10..456f3cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -836,6 +836,7 @@ dependencies = [ "percent-encoding", "thiserror 2.0.19", "urlencoding", + "xmltree", ] [[package]] @@ -6337,6 +6338,21 @@ dependencies = [ "rustix", ] +[[package]] +name = "xml" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" + +[[package]] +name = "xmltree" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc04313cab124e498ab1724e739720807b6dc405b9ed0edc5860164d2e4ff70" +dependencies = [ + "xml", +] + [[package]] name = "xxhash-rust" version = "0.8.18" diff --git a/Cargo.toml b/Cargo.toml index 41700fe..607d169 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,3 +88,4 @@ utoipa = { version = "5.5.0", features = ["chrono", "actix_extras", "repr"] } url = "2" urlencoding = "2" uuid = { version = "1", features = ["v4"] } +xmltree = "0.12" diff --git a/crates/aster_forge_webdav/Cargo.toml b/crates/aster_forge_webdav/Cargo.toml index d049982..0f24b89 100644 --- a/crates/aster_forge_webdav/Cargo.toml +++ b/crates/aster_forge_webdav/Cargo.toml @@ -22,3 +22,4 @@ http.workspace = true percent-encoding.workspace = true thiserror.workspace = true urlencoding.workspace = true +xmltree.workspace = true diff --git a/crates/aster_forge_webdav/src/backend.rs b/crates/aster_forge_webdav/src/backend.rs index 0657d95..691c7c9 100644 --- a/crates/aster_forge_webdav/src/backend.rs +++ b/crates/aster_forge_webdav/src/backend.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use bytes::Bytes; use futures::Stream; -use crate::{DavPath, Depth}; +use crate::{DavPath, DavXmlElement, Depth}; /// Stream used for product-independent WebDAV content transfer. pub type DavContentStream = @@ -132,7 +132,7 @@ pub struct DavPropertyName { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DavProperty { pub name: DavPropertyName, - pub xml: Option>, + pub xml: Option, } /// One property set/remove mutation. @@ -168,7 +168,7 @@ pub trait DavPropertyBackend: Send + Sync { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DavLockRequest { pub path: DavPath, - pub owner_xml: Option>, + pub owner_xml: Option, pub timeout: Option, pub shared: bool, pub deep: bool, @@ -179,7 +179,7 @@ pub struct DavLockRequest { pub struct DavLockInfo { pub token: String, pub path: DavPath, - pub owner_xml: Option>, + pub owner_xml: Option, pub timeout_at: Option, pub timeout: Option, pub shared: bool, diff --git a/crates/aster_forge_webdav/src/lib.rs b/crates/aster_forge_webdav/src/lib.rs index 579f731..8c6fc07 100644 --- a/crates/aster_forge_webdav/src/lib.rs +++ b/crates/aster_forge_webdav/src/lib.rs @@ -24,6 +24,7 @@ pub mod path; pub mod protocol; pub mod request; pub mod response; +pub mod xml; pub use backend::{ DavBackend, DavBackendError, DavBackendErrorKind, DavContentStream, DavDirectoryEntry, @@ -45,3 +46,8 @@ pub use protocol::{ }; pub use request::{DavMethod, DavRequestHead, DavRequestOrigin}; pub use response::{DavResponse, DavResponseBody}; +pub use xml::{ + DavLockRequestBody, DavPropertyPatchRequest, DavPropertyPatchValue, DavPropfindRequest, + DavRequestedProperty, DavXmlElement, DavXmlError, DavXmlNode, parse_lock_request, + parse_propfind_request, parse_proppatch_request, parse_report_root, +}; diff --git a/crates/aster_forge_webdav/src/xml.rs b/crates/aster_forge_webdav/src/xml.rs new file mode 100644 index 0000000..43cac7e --- /dev/null +++ b/crates/aster_forge_webdav/src/xml.rs @@ -0,0 +1,444 @@ +//! 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::{Cursor, Read}; + +use aster_forge_utils::xml::{XmlSafetyError, XmlSafetyPolicy, validate_xml_input}; +use xmltree::{Element, XMLNode}; + +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 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::Malformed | XmlSafetyError::InvalidPolicy => 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), +} + +/// XML element whose concrete parser/serializer is private to AsterForge. +#[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(mut reader: impl Read) -> Result { + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|_| DavXmlError::Malformed)?; + Self::parse(&bytes) + } + + /// Serializes the element as UTF-8 XML bytes. + pub fn to_bytes(&self) -> Result, DavXmlError> { + let mut bytes = Vec::new(); + element_to_xmltree(self) + .write(&mut bytes) + .map_err(|_| DavXmlError::Malformed)?; + Ok(bytes) + } + + /// 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 root = parse_element(body)?; + 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 root = parse_element(body)?; + 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.as_deref() == 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 = property.clone(); + 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 root = parse_element(body)?; + 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.as_deref() == 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.as_deref() == 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") && owner.replace(child.clone()).is_some() { + return Err(DavXmlError::InvalidGrammar); + } + } + 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 root = parse_element(body)?; + Ok(requested_property(&root)) +} + +fn parse_element(bytes: &[u8]) -> Result { + validate_xml_input(bytes, XmlSafetyPolicy::untrusted())?; + Element::parse(Cursor::new(bytes)) + .map(element_from_xmltree) + .map_err(|_| DavXmlError::Malformed) +} + +fn is_dav_element(element: &DavXmlElement, local_name: &str) -> bool { + element.name == local_name && element.namespace.as_deref() == Some(DAV_NAMESPACE) +} + +fn requested_property(element: &DavXmlElement) -> DavRequestedProperty { + DavRequestedProperty { + name: element.name.clone(), + namespace: element.namespace.clone(), + prefix: element.prefix.clone(), + } +} + +fn xml_lang_value(element: &DavXmlElement) -> Option<&str> { + element + .attributes + .get("xml:lang") + .or_else(|| element.attributes.get("lang")) + .map(String::as_str) +} + +fn element_from_xmltree(element: Element) -> DavXmlElement { + DavXmlElement { + name: element.name, + prefix: element.prefix, + namespace: element.namespace, + namespaces: element + .namespaces + .map(|namespaces| { + namespaces + .iter() + .map(|(prefix, namespace)| (prefix.to_owned(), namespace.to_owned())) + .collect() + }) + .unwrap_or_default(), + attributes: element.attributes.into_iter().collect(), + children: element + .children + .into_iter() + .map(|child| match child { + XMLNode::Element(element) => DavXmlNode::Element(element_from_xmltree(element)), + XMLNode::Text(text) => DavXmlNode::Text(text), + XMLNode::CData(text) => DavXmlNode::CData(text), + XMLNode::Comment(text) => DavXmlNode::Comment(text), + XMLNode::ProcessingInstruction(name, value) => { + DavXmlNode::ProcessingInstruction(name, value) + } + }) + .collect(), + } +} + +fn element_to_xmltree(element: &DavXmlElement) -> Element { + let mut result = Element::new(&element.name); + result.prefix.clone_from(&element.prefix); + result.namespace.clone_from(&element.namespace); + if !element.namespaces.is_empty() { + let mut namespaces = xmltree::Namespace::empty(); + for (prefix, namespace) in &element.namespaces { + namespaces.force_put(prefix.clone(), namespace.clone()); + } + result.namespaces = Some(namespaces); + } + result.attributes.extend(element.attributes.clone()); + result.children = element + .children + .iter() + .map(|child| match child { + DavXmlNode::Element(element) => XMLNode::Element(element_to_xmltree(element)), + DavXmlNode::Text(text) => XMLNode::Text(text.clone()), + DavXmlNode::CData(text) => XMLNode::CData(text.clone()), + DavXmlNode::Comment(text) => XMLNode::Comment(text.clone()), + DavXmlNode::ProcessingInstruction(name, value) => { + XMLNode::ProcessingInstruction(name.clone(), value.clone()) + } + }) + .collect(); + result +} diff --git a/crates/aster_forge_webdav/tests/xml.rs b/crates/aster_forge_webdav/tests/xml.rs new file mode 100644 index 0000000..878cce1 --- /dev/null +++ b/crates/aster_forge_webdav/tests/xml.rs @@ -0,0 +1,300 @@ +use aster_forge_utils::xml::DEFAULT_XML_MAX_DEPTH; +use aster_forge_webdav::{ + DavPropfindRequest, DavXmlElement, DavXmlError, DavXmlNode, parse_lock_request, + parse_propfind_request, parse_proppatch_request, parse_report_root, +}; + +#[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_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_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/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md index 1732bfb..72909be 100644 --- a/docs/crates/aster_forge_webdav.md +++ b/docs/crates/aster_forge_webdav.md @@ -21,6 +21,8 @@ Forge 负责: - WebDAV 方法、`Depth`、`Overwrite`、`Destination` 和 `If` header 解析。 - HTTP ETag、`If-Modified-Since`、`If-Unmodified-Since` 的协议优先级。 - `DavRequestHead`、`DavResponse`、`DavEvent` 等协议模型。 +- PROPFIND、PROPPATCH、LOCK、REPORT 的 XML 安全校验、QName 语法和未知扩展处理。 +- `DavXmlElement` XML 表示与序列化边界;具体 XML crate 是 Forge 私有实现,产品不直接依赖。 - `DavResourceBackend`、`DavPropertyBackend`、`DavLockBackend` 和可选 `DavVersionBackend` port。 - Actix transport 与 transport-neutral `http` 类型的显式转换。 @@ -47,10 +49,10 @@ Forge 负责: ## 测试要求 - 协议 crate 测试路径逃逸、header grammar、同源 `Destination`、条件请求和 request-head 解析。 +- XML 边界矩阵覆盖空体、QName 冲突、未知子树、重复/互斥控制、DTD/ENTITY、深度临界、UTF-8、转义和大属性值。 - 产品仓库保留真实认证、数据库、存储、quota、audit 和客户端集成测试。 - Litmus、rclone、curl、cadaver 兼容测试仍应针对具体产品 server 运行,因为它们验证的是协议层和产品 adapter 的组合结果。 ## 参考项目 - AsterDrive:`src/webdav/` 保留产品 adapter;`tests/webdav/` 和 WebDAV compatibility workflow 验证完整产品行为。 - From d5bc019bc9ee3db3f3e21a7c37ffb613cd675c19 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 24 Jul 2026 21:58:02 +0800 Subject: [PATCH 03/11] feat(aster_forge_webdav): add WebDAV XML response grammar Add product-neutral XML response generation for WebDAV protocol: - **Core response structures:** - `DavPropStat`: property status groups with HTTP status and property list - `DavMultiStatusItem`: response entries supporting both property-based and resource-wide status patterns - `DavErrorCondition`: RFC 4918 preconditions and postconditions (no-external-entities, lock-token-submitted, lock-token-matches-request-uri, propfind-finite-depth) - `DavLockXml`: protocol-visible lock values for lockdiscovery rendering - `DavVersionXml`: DeltaV version-tree entry representation - **Response builders:** - `dav_multistatus_element`: complete multistatus documents with response/propstat hierarchy - `dav_error_element`: error condition documents with proper namespace declarations - `dav_status_element`: HTTP status-line formatting with canonical reason phrases - `dav_propstat_element`, `dav_response_element`: composable response fragments - **Property builders:** - `dav_property_name_element`, `dav_property_text_element`, `dav_property_child_element`: QName-preserving property construction with namespace handling - `dav_dead_property_element`: dead property reconstruction from persisted XML with attribute preservation and malformed-value escaping fallback - **Lock and versioning:** - `dav_supported_lock_element`: RFC 4918 supportedlock with exclusive/shared write entries - `dav_lock_discovery_element`: activelock rendering with owner, timeout, token, depth, and root - `dav_lock_response_element`: LOCK response prop documents - `dav_version_multistatus_element`: DeltaV version-tree multistatus with protocol property order - **Test coverage:** - Response/propstat/property ordering preservation - Namespace declaration and QName handling for DAV:, custom, and no-namespace properties - Error condition document generation and href escaping - Lock field coverage (scope, depth, timeout variants, owner, token percent-encoding) - Dead property validation with attribute preservation and legacy value escaping - DeltaV multistatus protocol property order and value escaping - All outputs validated for well-formed XML Forge now handles the complete outbound XML grammar for multistatus, error, lock response, and version-tree documents. Products call builders with semantic data structures and receive validated XML without coupling to quick-xml or raw serialization logic. --- crates/aster_forge_webdav/src/lib.rs | 9 + crates/aster_forge_webdav/src/xml_response.rs | 434 ++++++++++++++++++ .../aster_forge_webdav/tests/xml_response.rs | 230 ++++++++++ docs/crates/aster_forge_webdav.md | 2 + 4 files changed, 675 insertions(+) create mode 100644 crates/aster_forge_webdav/src/xml_response.rs create mode 100644 crates/aster_forge_webdav/tests/xml_response.rs diff --git a/crates/aster_forge_webdav/src/lib.rs b/crates/aster_forge_webdav/src/lib.rs index 8c6fc07..b3ac1a5 100644 --- a/crates/aster_forge_webdav/src/lib.rs +++ b/crates/aster_forge_webdav/src/lib.rs @@ -25,6 +25,7 @@ pub mod protocol; pub mod request; pub mod response; pub mod xml; +pub mod xml_response; pub use backend::{ DavBackend, DavBackendError, DavBackendErrorKind, DavContentStream, DavDirectoryEntry, @@ -51,3 +52,11 @@ pub use xml::{ 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/xml_response.rs b/crates/aster_forge_webdav/src/xml_response.rs new file mode 100644 index 0000000..5e8ce07 --- /dev/null +++ b/crates/aster_forge_webdav/src/xml_response.rs @@ -0,0 +1,434 @@ +//! 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)); + 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)); + active.children.push(DavXmlNode::Element(dav_text_element( + "depth", + if lock.deep { "Infinity" } else { "0" }, + ))); + + 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() + .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/xml_response.rs b/crates/aster_forge_webdav/tests/xml_response.rs new file mode 100644 index 0000000..10f7cff --- /dev/null +++ b/crates/aster_forge_webdav/tests/xml_response.rs @@ -0,0 +1,230 @@ +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("".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/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md index 72909be..93121ca 100644 --- a/docs/crates/aster_forge_webdav.md +++ b/docs/crates/aster_forge_webdav.md @@ -23,6 +23,7 @@ Forge 负责: - `DavRequestHead`、`DavResponse`、`DavEvent` 等协议模型。 - PROPFIND、PROPPATCH、LOCK、REPORT 的 XML 安全校验、QName 语法和未知扩展处理。 - `DavXmlElement` XML 表示与序列化边界;具体 XML crate 是 Forge 私有实现,产品不直接依赖。 +- DAV error、multistatus/propstat、dead property、supportedlock/lockdiscovery 和 DeltaV version-tree 的 response grammar。 - `DavResourceBackend`、`DavPropertyBackend`、`DavLockBackend` 和可选 `DavVersionBackend` port。 - Actix transport 与 transport-neutral `http` 类型的显式转换。 @@ -50,6 +51,7 @@ Forge 负责: - 协议 crate 测试路径逃逸、header grammar、同源 `Destination`、条件请求和 request-head 解析。 - XML 边界矩阵覆盖空体、QName 冲突、未知子树、重复/互斥控制、DTD/ENTITY、深度临界、UTF-8、转义和大属性值。 +- XML response 矩阵覆盖状态行、元素顺序、QName、命名空间声明、锁字段、死属性重建和异常旧值转义。 - 产品仓库保留真实认证、数据库、存储、quota、audit 和客户端集成测试。 - Litmus、rclone、curl、cadaver 兼容测试仍应针对具体产品 server 运行,因为它们验证的是协议层和产品 adapter 的组合结果。 From 1004dba6048653e2f2dda8a8b8079bbfc3eef9ab Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 24 Jul 2026 23:13:53 +0800 Subject: [PATCH 04/11] feat(aster_forge_webdav): add lock timeout and token parsing with transport-neutral status - Add `parse_lock_timeout` with bounded server policy validation - Add `parse_lock_token_header` for angle-bracketed token extraction - Add `submitted_lock_tokens` helper accepting parsed `IfHeader` - Change `DavEventOutcome` status fields from `http::StatusCode` to `u16` for transport neutrality - Add `DavEventOutcome::from_status` constructor classifying HTTP boundaries - Add `DavEventOutcome::status` accessor for completed status code - Export new parsing functions in public API - Add comprehensive test coverage for timeout bounds, token format, and outcome classification - Update documentation noting transport-neutral status and expanded header support --- crates/aster_forge_webdav/src/event.rs | 30 +++++- crates/aster_forge_webdav/src/lib.rs | 5 +- crates/aster_forge_webdav/src/protocol.rs | 62 +++++++++++- crates/aster_forge_webdav/tests/event.rs | 27 ++++++ crates/aster_forge_webdav/tests/protocol.rs | 101 +++++++++++++++++++- docs/crates/aster_forge_webdav.md | 4 +- 6 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 crates/aster_forge_webdav/tests/event.rs diff --git a/crates/aster_forge_webdav/src/event.rs b/crates/aster_forge_webdav/src/event.rs index fff8bb0..5920b4c 100644 --- a/crates/aster_forge_webdav/src/event.rs +++ b/crates/aster_forge_webdav/src/event.rs @@ -27,14 +27,40 @@ pub enum DavOperation { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DavEventOutcome { Succeeded { - status: http::StatusCode, + /// HTTP/WebDAV response status without leaking a transport crate version. + status: u16, }, Failed { - status: http::StatusCode, + /// 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 { diff --git a/crates/aster_forge_webdav/src/lib.rs b/crates/aster_forge_webdav/src/lib.rs index b3ac1a5..9134e41 100644 --- a/crates/aster_forge_webdav/src/lib.rs +++ b/crates/aster_forge_webdav/src/lib.rs @@ -42,8 +42,9 @@ pub use protocol::{ DavPrecondition, DavProtocolError, DavProtocolErrorKind, Depth, Destination, IfHeader, IfResourceGroup, IfStateCondition, IfStateList, destination_relative_path, evaluate_http_download_preconditions, evaluate_http_etag_preconditions, parse_copy_depth, - parse_delete_depth, parse_if_header, parse_lock_depth, parse_move_depth, parse_overwrite, - parse_propfind_depth, submitted_lock_tokens_for_path, + 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 request::{DavMethod, DavRequestHead, DavRequestOrigin}; pub use response::{DavResponse, DavResponseBody}; diff --git a/crates/aster_forge_webdav/src/protocol.rs b/crates/aster_forge_webdav/src/protocol.rs index 7d67ab2..7e246e7 100644 --- a/crates/aster_forge_webdav/src/protocol.rs +++ b/crates/aster_forge_webdav/src/protocol.rs @@ -1,6 +1,6 @@ //! WebDAV header parsing and protocol precondition rules. -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use http::header::{self, HeaderMap, HeaderValue}; use http::{StatusCode, Uri}; @@ -265,6 +265,17 @@ pub fn submitted_lock_tokens_for_path( 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() { @@ -287,6 +298,55 @@ pub fn submitted_lock_tokens_for_path( 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()) + { + let timeout = Duration::from_secs(seconds); + if timeout > maximum { + return Err(DavProtocolError::bad_request("Invalid Timeout header")); + } + return Ok(timeout); + } + } + 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, diff --git a/crates/aster_forge_webdav/tests/event.rs b/crates/aster_forge_webdav/tests/event.rs new file mode 100644 index 0000000..dbb7bdd --- /dev/null +++ b/crates/aster_forge_webdav/tests/event.rs @@ -0,0 +1,27 @@ +use aster_forge_webdav::{DavBackendErrorKind, DavEventOutcome}; + +#[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); +} diff --git a/crates/aster_forge_webdav/tests/protocol.rs b/crates/aster_forge_webdav/tests/protocol.rs index acfc2d6..5e22a98 100644 --- a/crates/aster_forge_webdav/tests/protocol.rs +++ b/crates/aster_forge_webdav/tests/protocol.rs @@ -5,7 +5,8 @@ use aster_forge_webdav::{ IfStateCondition, child_relative_path, destination_relative_path, 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_move_depth, parse_propfind_depth, submitted_lock_tokens_for_path, + parse_lock_timeout, parse_lock_token_header, parse_move_depth, parse_propfind_depth, + submitted_lock_tokens, submitted_lock_tokens_for_path, }; use http::header::{self, HeaderMap, HeaderName, HeaderValue}; use http::{Method, Uri}; @@ -294,6 +295,104 @@ fn submitted_tokens_ignore_other_resources_and_lock_token_header() { ); } +#[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", "Extension"] { + assert!( + parse_lock_timeout(&headers("Timeout", value), maximum).is_err(), + "{value} should be rejected" + ); + } + + 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(); diff --git a/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md index 93121ca..aceb779 100644 --- a/docs/crates/aster_forge_webdav.md +++ b/docs/crates/aster_forge_webdav.md @@ -18,7 +18,7 @@ aster_forge_webdav = { git = "https://github.com/AsterCommunity/AsterForge", fea Forge 负责: - `DavPath` 的百分号解码、dot-segment 规范化和 mount escape 拒绝。 -- WebDAV 方法、`Depth`、`Overwrite`、`Destination` 和 `If` header 解析。 +- WebDAV 方法、`Depth`、`Overwrite`、`Destination`、`If`、`Timeout` 和 `Lock-Token` header 解析。 - HTTP ETag、`If-Modified-Since`、`If-Unmodified-Since` 的协议优先级。 - `DavRequestHead`、`DavResponse`、`DavEvent` 等协议模型。 - PROPFIND、PROPPATCH、LOCK、REPORT 的 XML 安全校验、QName 语法和未知扩展处理。 @@ -39,7 +39,7 @@ Forge 负责: 产品应把已认证、已限定 workspace 的 adapter 交给协议层。backend 调用必须同步完成影响协议正确性的操作;quota、blob 引用、lock 持久化和必要的缓存失效不能依赖事件补写。 -`DavEventSink` 只观察已经完成的协议操作,适合 tracing、metrics、审计适配和通知。事件不包含请求正文、凭据或 lock token。 +`DavEventSink` 只观察已经完成的协议操作,适合 tracing、metrics、审计适配和通知。事件使用 transport-neutral `u16` 状态码,不包含请求正文、凭据或 lock token。 ## 错误边界 From 519fdbe81fec9d68a27a7ee1ad573c4e16d0723b Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Sat, 25 Jul 2026 00:38:40 +0800 Subject: [PATCH 05/11] feat(webdav): add HTTP range parsing and download response planning - Add `http_range` module to `aster_forge_utils` with transport-neutral single byte-range parsing - `parse_single_byte_range` handles bounded, open-ended, and suffix ranges - `HttpByteRange` resolves and validates ranges against representation length - Clamps out-of-bounds end values and distinguishes unsatisfiable/malformed/multi-range errors - Generates `Content-Range` header values for 206/416 responses - Add body policy classification to `DavMethod` with `Empty`, `BoundedXml`, `Stream`, and `Unused` variants - Add Actix body adapters for enforcing empty-body and bounded-XML policies before protocol execution - Add `plan_download_response` to build complete GET/HEAD response shells with 200/206/304/416 status selection - Evaluates HTTP preconditions and byte ranges in protocol order - Returns `DavDownloadPlan` with response headers and storage read contract (`Full`, `Range`, or `Empty`) - Handles HEAD method, conditional requests, and range validation without touching storage - Add product-neutral response builders: `options_response`, `method_not_allowed_response`, `body_error_response`, `range_not_satisfiable_response` - Add comprehensive test coverage for range parsing edge cases, body policy enforcement, and download response planning - Update documentation with HTTP range semantics, body policy contracts, and response planning responsibilities --- crates/aster_forge_utils/src/http_range.rs | 215 +++++++++++++++++ crates/aster_forge_utils/src/lib.rs | 7 +- crates/aster_forge_webdav/src/actix.rs | 35 ++- crates/aster_forge_webdav/src/lib.rs | 8 +- crates/aster_forge_webdav/src/request.rs | 28 +++ crates/aster_forge_webdav/src/response.rs | 221 ++++++++++++++++- crates/aster_forge_webdav/tests/actix.rs | 45 ++++ crates/aster_forge_webdav/tests/protocol.rs | 48 +++- crates/aster_forge_webdav/tests/response.rs | 253 ++++++++++++++++++++ docs/crates/aster_forge_utils.md | 16 ++ docs/crates/aster_forge_webdav.md | 3 + 11 files changed, 864 insertions(+), 15 deletions(-) create mode 100644 crates/aster_forge_utils/src/http_range.rs create mode 100644 crates/aster_forge_webdav/tests/actix.rs create mode 100644 crates/aster_forge_webdav/tests/response.rs 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..ef94a51 --- /dev/null +++ b/crates/aster_forge_utils/src/http_range.rs @@ -0,0 +1,215 @@ +//! 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 range = raw + .strip_prefix("bytes=") + .ok_or(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) + ); + } + + #[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 51f53a5..77cb9ad 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/src/actix.rs b/crates/aster_forge_webdav/src/actix.rs index 34ac025..bd4454f 100644 --- a/crates/aster_forge_webdav/src/actix.rs +++ b/crates/aster_forge_webdav/src/actix.rs @@ -6,7 +6,9 @@ use futures::StreamExt; use http::{HeaderMap, HeaderName, HeaderValue, Uri}; use crate::protocol::DavProtocolError; -use crate::{DavMethod, DavRequestHead, DavRequestOrigin, DavResponse, DavResponseBody}; +use crate::{ + DavBodyError, DavMethod, DavRequestHead, DavRequestOrigin, DavResponse, DavResponseBody, +}; /// Parses an Actix request into the transport-neutral request head. pub fn request_head( @@ -66,3 +68,34 @@ pub fn convert_header_map(source: &actix_header::HeaderMap) -> Result 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) +} diff --git a/crates/aster_forge_webdav/src/lib.rs b/crates/aster_forge_webdav/src/lib.rs index 9134e41..6874f1d 100644 --- a/crates/aster_forge_webdav/src/lib.rs +++ b/crates/aster_forge_webdav/src/lib.rs @@ -46,8 +46,12 @@ pub use protocol::{ parse_lock_token_header, parse_move_depth, parse_overwrite, parse_propfind_depth, submitted_lock_tokens, submitted_lock_tokens_for_path, }; -pub use request::{DavMethod, DavRequestHead, DavRequestOrigin}; -pub use response::{DavResponse, DavResponseBody}; +pub use request::{DavBodyPolicy, DavMethod, DavRequestHead, DavRequestOrigin}; +pub use response::{ + DAV_ALLOW_HEADER, DavBodyError, DavDownloadBody, DavDownloadPlan, DavDownloadPlanError, + DavResponse, DavResponseBody, body_error_response, method_not_allowed_response, + options_response, plan_download_response, range_not_satisfiable_response, +}; pub use xml::{ DavLockRequestBody, DavPropertyPatchRequest, DavPropertyPatchValue, DavPropfindRequest, DavRequestedProperty, DavXmlElement, DavXmlError, DavXmlNode, parse_lock_request, diff --git a/crates/aster_forge_webdav/src/request.rs b/crates/aster_forge_webdav/src/request.rs index ab2efa3..dd9aa0a 100644 --- a/crates/aster_forge_webdav/src/request.rs +++ b/crates/aster_forge_webdav/src/request.rs @@ -29,6 +29,19 @@ pub enum DavMethod { 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] @@ -78,6 +91,21 @@ impl DavMethod { 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. diff --git a/crates/aster_forge_webdav/src/response.rs b/crates/aster_forge_webdav/src/response.rs index 06d967f..241036f 100644 --- a/crates/aster_forge_webdav/src/response.rs +++ b/crates/aster_forge_webdav/src/response.rs @@ -1,9 +1,57 @@ -//! Transport-neutral WebDAV response model. +//! Transport-neutral WebDAV response model and download response planning. +use std::time::SystemTime; + +use aster_forge_utils::http_range::{HttpByteRange, parse_single_byte_range}; +use aster_forge_utils::http_validators::format_http_date; use bytes::Bytes; -use http::{HeaderMap, StatusCode}; +use http::header::{ + ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, + CONTENT_TYPE, ETAG, LAST_MODIFIED, RANGE, +}; +use http::{HeaderMap, HeaderValue, StatusCode}; + +use crate::{DavContentStream, DavPrecondition, DavProtocolError}; + +/// 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, +} -use crate::DavContentStream; +/// 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 { @@ -40,3 +88,170 @@ impl DavResponse { } } } + +/// 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 { + 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(_) => 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 }) +} + +/// 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/tests/actix.rs b/crates/aster_forge_webdav/tests/actix.rs new file mode 100644 index 0000000..d7c9974 --- /dev/null +++ b/crates/aster_forge_webdav/tests/actix.rs @@ -0,0 +1,45 @@ +#![cfg(feature = "actix")] + +use actix_web::{FromRequest, web}; +use aster_forge_webdav::DavBodyError; +use bytes::Bytes; + +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") +} + +#[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) + ); +} diff --git a/crates/aster_forge_webdav/tests/protocol.rs b/crates/aster_forge_webdav/tests/protocol.rs index 5e22a98..a8e2d48 100644 --- a/crates/aster_forge_webdav/tests/protocol.rs +++ b/crates/aster_forge_webdav/tests/protocol.rs @@ -1,12 +1,13 @@ use std::time::{Duration, UNIX_EPOCH}; use aster_forge_webdav::{ - DavMethod, DavPath, DavPathError, DavPrecondition, DavRequestHead, DavRequestOrigin, Depth, - IfStateCondition, child_relative_path, destination_relative_path, - 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, + DAV_ALLOW_HEADER, DavBodyPolicy, DavMethod, DavPath, DavPathError, DavPrecondition, + DavRequestHead, DavRequestOrigin, Depth, IfStateCondition, child_relative_path, + destination_relative_path, 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 http::header::{self, HeaderMap, HeaderName, HeaderValue}; use http::{Method, Uri}; @@ -93,6 +94,41 @@ fn method_parser_recognizes_webdav_extensions() { 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!( diff --git a/crates/aster_forge_webdav/tests/response.rs b/crates/aster_forge_webdav/tests/response.rs new file mode 100644 index 0000000..60094da --- /dev/null +++ b/crates/aster_forge_webdav/tests/response.rs @@ -0,0 +1,253 @@ +use std::time::{Duration, UNIX_EPOCH}; + +use aster_forge_webdav::{ + DAV_ALLOW_HEADER, DavBodyError, DavDownloadBody, DavDownloadPlanError, DavProtocolErrorKind, + DavResponseBody, body_error_response, method_not_allowed_response, options_response, + plan_download_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, LAST_MODIFIED, RANGE, +}; +use http::{HeaderMap, HeaderValue}; + +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 [ + ("items=0-1", 20), + ("bytes=0-1,3-4", 20), + ("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 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}") + ); + } +} diff --git a/docs/crates/aster_forge_utils.md b/docs/crates/aster_forge_utils.md index 8c7318a..a2e56f1 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: @@ -265,6 +279,8 @@ let report_type = xml_root_local_name(body, XmlSafetyPolicy::untrusted())?; - 每个产品接入点覆盖非法输入。 - 数值转换测试要包含负数、超上限和边界值。 - URL/origin 测试要覆盖 loopback HTTP、HTTPS、wildcard。 +- HTTP range 测试要覆盖 bounded/open-ended/suffix、end 截断、空文件、零 suffix、多段请求、 + `u64` 上界和 unsatisfiable offset。 - trusted proxy 测试要覆盖多代理链。 - XML 测试要覆盖正常输入、精确深度上限、超限一层、空文档、多个根元素、标签错配、 DTD/ENTITY 大小写变体、尾部不完整 markup,以及允许 DTD 的显式策略分支。 diff --git a/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md index aceb779..f21fe76 100644 --- a/docs/crates/aster_forge_webdav.md +++ b/docs/crates/aster_forge_webdav.md @@ -19,13 +19,16 @@ Forge 负责: - `DavPath` 的百分号解码、dot-segment 规范化和 mount escape 拒绝。 - WebDAV 方法、`Depth`、`Overwrite`、`Destination`、`If`、`Timeout` 和 `Lock-Token` header 解析。 +- 每个 DAV 方法的 empty/bounded XML/stream/unused body policy,以及 Actix bounded-body adapter。 - HTTP ETag、`If-Modified-Since`、`If-Unmodified-Since` 的协议优先级。 +- GET/HEAD 的 200/206/304/416 response planning、单段 byte range 选择与读取区间。 - `DavRequestHead`、`DavResponse`、`DavEvent` 等协议模型。 - PROPFIND、PROPPATCH、LOCK、REPORT 的 XML 安全校验、QName 语法和未知扩展处理。 - `DavXmlElement` XML 表示与序列化边界;具体 XML crate 是 Forge 私有实现,产品不直接依赖。 - DAV error、multistatus/propstat、dead property、supportedlock/lockdiscovery 和 DeltaV version-tree 的 response grammar。 - `DavResourceBackend`、`DavPropertyBackend`、`DavLockBackend` 和可选 `DavVersionBackend` port。 - Actix transport 与 transport-neutral `http` 类型的显式转换。 +- OPTIONS、405、body-policy failure 和 download response 的 product-neutral response shell。 产品负责: From 463440d6894ae3898a41426401daddbcd6298896 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Sat, 25 Jul 2026 03:41:09 +0800 Subject: [PATCH 06/11] feat(aster_forge_webdav): add protocol planning layer for LOCK, PUT, resource mutation, property, DeltaV, and If enforcement - Add `DavIfResourceState` and `DavIfStateResolver` port to backend for `If` header resolution - Implement `enforce_if_header` with tagged-resource scoping, AND/OR/Not state-list evaluation, and scheme/host/mount normalization - Add `DavIfEvaluationError` separating protocol and backend failures - Add `lock` module: `plan_lock_request` selects acquire/refresh, validates timeout/token/body; success/conflict/limit/unlock response builders - Add `put` module: `plan_put_request` evaluates ETag preconditions and selects create/create_new flags; 201/204 response with `Content-Location` - Add `resource` module: `plan_copy_move_request` enforces depth, descendant, and overwrite rules; `validate_delete_target`/`validate_collection_create_target`; typed `DavMutationFailure` with locked DAV-error207 multistatus - Add `property` module: `build_propfind_item` handles allprop/include dedup, propname, prop with200/404 propstat grouping; `build_proppatch_item` groups outcomes by status; `plan_atomic_proppatch` assigns403/424 on protected properties; `format_creation_date` formats RFC 3339 UTC via `chrono` - Add `deltav` module: `validate_version_tree_report` selects DAV:version-tree grammar; `version_tree_response` composes 207 multistatus; `version_control_response` selects method-level status by resource kind - Add `DavPreparedBody` and `prepare_request_body` to actix adapter for method-owned body policy dispatch - Expose `origin` field on `DavRequestHead` after parse - Add `protocol_error_response` and `backend_error_response` to centralize error-to-status mapping - Add `xml_document_response`, `text_document_response`, `xml_request_error_response` internal helpers in `response` - Add comprehensive integration tests for all new modules --- Cargo.lock | 1 + crates/aster_forge_webdav/Cargo.toml | 1 + crates/aster_forge_webdav/src/actix.rs | 37 ++- crates/aster_forge_webdav/src/backend.rs | 14 ++ crates/aster_forge_webdav/src/deltav.rs | 109 +++++++++ crates/aster_forge_webdav/src/lib.rs | 54 ++++- crates/aster_forge_webdav/src/lock.rs | 203 ++++++++++++++++ crates/aster_forge_webdav/src/property.rs | 204 ++++++++++++++++ crates/aster_forge_webdav/src/protocol.rs | 96 +++++++- crates/aster_forge_webdav/src/put.rs | 109 +++++++++ crates/aster_forge_webdav/src/request.rs | 2 + crates/aster_forge_webdav/src/resource.rs | 247 ++++++++++++++++++++ crates/aster_forge_webdav/src/response.rs | 90 ++++++- crates/aster_forge_webdav/tests/actix.rs | 30 ++- crates/aster_forge_webdav/tests/deltav.rs | 110 +++++++++ crates/aster_forge_webdav/tests/lock.rs | 183 +++++++++++++++ crates/aster_forge_webdav/tests/property.rs | 180 ++++++++++++++ crates/aster_forge_webdav/tests/protocol.rs | 213 ++++++++++++++++- crates/aster_forge_webdav/tests/put.rs | 74 ++++++ crates/aster_forge_webdav/tests/resource.rs | 157 +++++++++++++ crates/aster_forge_webdav/tests/response.rs | 61 ++++- docs/crates/aster_forge_webdav.md | 7 + 22 files changed, 2156 insertions(+), 26 deletions(-) create mode 100644 crates/aster_forge_webdav/src/deltav.rs create mode 100644 crates/aster_forge_webdav/src/lock.rs create mode 100644 crates/aster_forge_webdav/src/property.rs create mode 100644 crates/aster_forge_webdav/src/put.rs create mode 100644 crates/aster_forge_webdav/src/resource.rs create mode 100644 crates/aster_forge_webdav/tests/deltav.rs create mode 100644 crates/aster_forge_webdav/tests/lock.rs create mode 100644 crates/aster_forge_webdav/tests/property.rs create mode 100644 crates/aster_forge_webdav/tests/put.rs create mode 100644 crates/aster_forge_webdav/tests/resource.rs diff --git a/Cargo.lock b/Cargo.lock index 456f3cd..f808c5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -831,6 +831,7 @@ dependencies = [ "aster_forge_utils", "async-trait", "bytes", + "chrono", "futures", "http 1.4.2", "percent-encoding", diff --git a/crates/aster_forge_webdav/Cargo.toml b/crates/aster_forge_webdav/Cargo.toml index 0f24b89..fc24ff0 100644 --- a/crates/aster_forge_webdav/Cargo.toml +++ b/crates/aster_forge_webdav/Cargo.toml @@ -17,6 +17,7 @@ actix-web = { workspace = true, optional = true } aster_forge_utils = { path = "../aster_forge_utils" } async-trait.workspace = true bytes.workspace = true +chrono = { workspace = true, features = ["clock"] } futures.workspace = true http.workspace = true percent-encoding.workspace = true diff --git a/crates/aster_forge_webdav/src/actix.rs b/crates/aster_forge_webdav/src/actix.rs index bd4454f..def9d37 100644 --- a/crates/aster_forge_webdav/src/actix.rs +++ b/crates/aster_forge_webdav/src/actix.rs @@ -7,9 +7,27 @@ use http::{HeaderMap, HeaderName, HeaderValue, Uri}; use crate::protocol::DavProtocolError; use crate::{ - DavBodyError, DavMethod, DavRequestHead, DavRequestOrigin, DavResponse, DavResponseBody, + DavBodyError, DavBodyPolicy, DavMethod, DavRequestHead, DavRequestOrigin, DavResponse, + DavResponseBody, }; +/// 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, @@ -99,3 +117,20 @@ pub async fn collect_bounded_xml_body( } 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 index 691c7c9..97e8286 100644 --- a/crates/aster_forge_webdav/src/backend.rs +++ b/crates/aster_forge_webdav/src/backend.rs @@ -62,6 +62,20 @@ pub struct DavResourceMetadata { pub modified_at: Option, } +/// Protocol-visible state used to evaluate one resource referenced by a WebDAV `If` header. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DavIfResourceState { + pub etag: Option, + pub lock_tokens: Vec, +} + +/// Product adapter used by the protocol layer while evaluating WebDAV `If` conditions. +#[async_trait] +pub trait DavIfStateResolver: Send + Sync { + async fn resolve_if_state(&self, path: &DavPath) + -> Result; +} + /// One child returned by a collection listing. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DavDirectoryEntry { diff --git a/crates/aster_forge_webdav/src/deltav.rs b/crates/aster_forge_webdav/src/deltav.rs new file mode 100644 index 0000000..4060f50 --- /dev/null +++ b/crates/aster_forge_webdav/src/deltav.rs @@ -0,0 +1,109 @@ +//! 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::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/lib.rs b/crates/aster_forge_webdav/src/lib.rs index 6874f1d..ea7a593 100644 --- a/crates/aster_forge_webdav/src/lib.rs +++ b/crates/aster_forge_webdav/src/lib.rs @@ -19,38 +19,70 @@ #[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::{ DavBackend, DavBackendError, DavBackendErrorKind, DavContentStream, DavDirectoryEntry, - DavLockBackend, DavLockInfo, DavLockRequest, DavProperty, DavPropertyBackend, DavPropertyName, - DavPropertyPatch, DavPropertyPatchOutcome, DavReadOutcome, DavResourceBackend, DavResourceKind, - DavResourceMetadata, DavVersionBackend, DavVersionInfo, DavWriteOutcome, DavWriteRequest, + DavIfResourceState, DavIfStateResolver, DavLockBackend, DavLockInfo, DavLockRequest, + DavProperty, DavPropertyBackend, DavPropertyName, DavPropertyPatch, DavPropertyPatchOutcome, + DavReadOutcome, DavResourceBackend, DavResourceKind, DavResourceMetadata, DavVersionBackend, + DavVersionInfo, DavWriteOutcome, DavWriteRequest, +}; +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, lock_acquire_success_response, lock_conflict_response, + lock_limit_response, lock_refresh_success_response, lock_xml_error_response, plan_lock_request, + unlock_success_response, unlock_token_mismatch_response, +}; 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::{ - DavPrecondition, DavProtocolError, DavProtocolErrorKind, Depth, Destination, IfHeader, - IfResourceGroup, IfStateCondition, IfStateList, destination_relative_path, - 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, + DavIfEvaluationError, DavPrecondition, DavProtocolError, DavProtocolErrorKind, Depth, + Destination, IfHeader, IfResourceGroup, IfStateCondition, IfStateList, + destination_relative_path, enforce_if_header, 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, + is_descendant_path, mutation_multistatus_response, mutation_plan_error_response, + mutation_success_response, plan_copy_move_request, 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, body_error_response, method_not_allowed_response, - options_response, plan_download_response, range_not_satisfiable_response, + 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, diff --git a/crates/aster_forge_webdav/src/lock.rs b/crates/aster_forge_webdav/src/lock.rs new file mode 100644 index 0000000..67453fb --- /dev/null +++ b/crates/aster_forge_webdav/src/lock.rs @@ -0,0 +1,203 @@ +//! 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::response::xml_request_error_response; +use crate::{ + DavErrorCondition, DavLockInfo, DavLockXml, DavPath, DavProtocolError, DavRequestHead, + DavResponse, DavXmlElement, DavXmlError, dav_error_element, dav_lock_response_element, + href_for_dav_path, parse_lock_request, parse_lock_timeout, 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), +} + +/// 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: &DavLockInfo, + status: StatusCode, + prefix: &str, + include_lock_token_header: bool, +) -> Result { + let body = dav_lock_response_element(&[DavLockXml { + token: lock.token.clone(), + owner: lock.owner_xml.clone(), + 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: &DavLockInfo, + 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: &DavLockInfo, + prefix: &str, + resource_existed: bool, +) -> Result { + lock_success_response( + lock, + if resource_existed { + StatusCode::OK + } else { + StatusCode::CREATED + }, + prefix, + true, + ) +} + +/// 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) +} + +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 +} 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 index 7e246e7..7de4783 100644 --- a/crates/aster_forge_webdav/src/protocol.rs +++ b/crates/aster_forge_webdav/src/protocol.rs @@ -5,7 +5,7 @@ use std::time::{Duration, SystemTime}; use http::header::{self, HeaderMap, HeaderValue}; use http::{StatusCode, Uri}; -use crate::DavPath; +use crate::{DavBackendError, DavIfResourceState, DavIfStateResolver, DavPath}; use aster_forge_utils::http_validators; /// WebDAV `Depth` header value. @@ -68,6 +68,15 @@ pub enum IfStateCondition { 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 { @@ -255,6 +264,91 @@ pub fn parse_if_header(headers: &HeaderMap) -> Result, DavProto 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 are OR-connected. Every +/// tagged resource group must have a matching list; an untagged header contains one request-target +/// group and follows the same list semantics. +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 Err(DavProtocolError::precondition_failed().into()); + } + } + Ok(()) +} + +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 !scheme.eq_ignore_ascii_case(request_scheme) + || !authority.as_str().eq_ignore_ascii_case(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, 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 index dd9aa0a..85162a7 100644 --- a/crates/aster_forge_webdav/src/request.rs +++ b/crates/aster_forge_webdav/src/request.rs @@ -120,6 +120,7 @@ pub struct DavRequestOrigin { pub struct DavRequestHead { pub method: DavMethod, pub target: DavPath, + pub origin: DavRequestOrigin, pub depth: Option, pub overwrite: Option, pub destination: Option, @@ -177,6 +178,7 @@ impl DavRequestHead { Ok(Self { method, target, + origin: origin.clone(), depth, overwrite, destination, diff --git a/crates/aster_forge_webdav/src/resource.rs b/crates/aster_forge_webdav/src/resource.rs new file mode 100644 index 0000000..7948063 --- /dev/null +++ b/crates/aster_forge_webdav/src/resource.rs @@ -0,0 +1,247 @@ +//! Resource mutation path rules and response composition. + +use http::header::{CACHE_CONTROL, CONTENT_LOCATION, CONTENT_TYPE}; +use http::{HeaderValue, StatusCode}; + +use crate::{ + DavErrorCondition, DavMultiStatusItem, DavPath, DavResourceKind, DavResponse, DavXmlError, + Depth, dav_multistatus_element, href_for_dav_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(()) + } +} + +/// 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, + }; + let mut response = DavResponse::empty(status); + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// 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}/")) +} + +/// 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 + }; + let mut response = DavResponse::empty(status); + response + .headers + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// 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 index 241036f..1b4a0cb 100644 --- a/crates/aster_forge_webdav/src/response.rs +++ b/crates/aster_forge_webdav/src/response.rs @@ -11,7 +11,10 @@ use http::header::{ }; use http::{HeaderMap, HeaderValue, StatusCode}; -use crate::{DavContentStream, DavPrecondition, DavProtocolError}; +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"; @@ -89,6 +92,91 @@ impl DavResponse { } } +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::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) +} + +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 { diff --git a/crates/aster_forge_webdav/tests/actix.rs b/crates/aster_forge_webdav/tests/actix.rs index d7c9974..3cb0878 100644 --- a/crates/aster_forge_webdav/tests/actix.rs +++ b/crates/aster_forge_webdav/tests/actix.rs @@ -1,8 +1,9 @@ #![cfg(feature = "actix")] use actix_web::{FromRequest, web}; -use aster_forge_webdav::DavBodyError; +use aster_forge_webdav::{DavBodyError, DavMethod}; use bytes::Bytes; +use futures::StreamExt; async fn payload_from_bytes(bytes: Bytes) -> web::Payload { let (request, mut payload) = actix_web::test::TestRequest::default() @@ -43,3 +44,30 @@ async fn bounded_xml_body_accepts_the_exact_limit_and_rejects_one_byte_over() { 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") + ); +} diff --git a/crates/aster_forge_webdav/tests/deltav.rs b/crates/aster_forge_webdav/tests/deltav.rs new file mode 100644 index 0000000..023eb87 --- /dev/null +++ b/crates/aster_forge_webdav/tests/deltav.rs @@ -0,0 +1,110 @@ +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")); +} + +#[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/lock.rs b/crates/aster_forge_webdav/tests/lock.rs new file mode 100644 index 0000000..1a23408 --- /dev/null +++ b/crates/aster_forge_webdav/tests/lock.rs @@ -0,0 +1,183 @@ +use std::time::Duration; + +use aster_forge_webdav::{ + DavLockInfo, DavLockPlan, DavLockPlanError, DavMethod, DavPath, DavProtocolErrorKind, + DavRequestHead, DavRequestOrigin, DavResponseBody, Depth, IfHeader, + lock_acquire_success_response, lock_conflict_response, 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 = DavLockInfo { + token: "urn:uuid:lock".to_string(), + path: DavPath::new("/a.txt").expect("path"), + owner_xml: 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(), + "" + ); +} + +#[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..df8f557 --- /dev/null +++ b/crates/aster_forge_webdav/tests/property.rs @@ -0,0 +1,180 @@ +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"); +} diff --git a/crates/aster_forge_webdav/tests/protocol.rs b/crates/aster_forge_webdav/tests/protocol.rs index a8e2d48..c9bd403 100644 --- a/crates/aster_forge_webdav/tests/protocol.rs +++ b/crates/aster_forge_webdav/tests/protocol.rs @@ -1,14 +1,18 @@ +use std::collections::HashMap; +use std::sync::Mutex; use std::time::{Duration, UNIX_EPOCH}; use aster_forge_webdav::{ - DAV_ALLOW_HEADER, DavBodyPolicy, DavMethod, DavPath, DavPathError, DavPrecondition, - DavRequestHead, DavRequestOrigin, Depth, IfStateCondition, child_relative_path, - destination_relative_path, 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, + 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}; @@ -21,6 +25,36 @@ fn headers(name: &'static str, value: &'static str) -> HeaderMap { 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") @@ -291,6 +325,169 @@ fn if_header_accepts_case_insensitive_not_and_groups_tagged_lists() { ); } +#[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_requires_every_tagged_resource_group_to_match() { + 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#" () ()"#); + let error = futures::executor::block_on(enforce_if_header( + Some(&partially_matching), + &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( @@ -477,6 +674,8 @@ fn request_head_parses_method_specific_contract() { .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!( 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..dd4e8d6 --- /dev/null +++ b/crates/aster_forge_webdav/tests/resource.rs @@ -0,0 +1,157 @@ +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, 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 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 index 60094da..b2e8f9c 100644 --- a/crates/aster_forge_webdav/tests/response.rs +++ b/crates/aster_forge_webdav/tests/response.rs @@ -1,16 +1,18 @@ use std::time::{Duration, UNIX_EPOCH}; use aster_forge_webdav::{ - DAV_ALLOW_HEADER, DavBodyError, DavDownloadBody, DavDownloadPlanError, DavProtocolErrorKind, - DavResponseBody, body_error_response, method_not_allowed_response, options_response, - plan_download_response, range_not_satisfiable_response, + 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, LAST_MODIFIED, RANGE, }; -use http::{HeaderMap, HeaderValue}; +use http::{HeaderMap, HeaderValue, Uri}; fn representation_time() -> std::time::SystemTime { UNIX_EPOCH + Duration::from_secs(784_111_777) @@ -251,3 +253,54 @@ fn direct_416_builder_handles_zero_and_maximum_representation_lengths() { ); } } + +#[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/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md index f21fe76..eb2ead9 100644 --- a/docs/crates/aster_forge_webdav.md +++ b/docs/crates/aster_forge_webdav.md @@ -19,11 +19,18 @@ 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。 +- 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 选择与读取区间。 - `DavRequestHead`、`DavResponse`、`DavEvent` 等协议模型。 - 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。 - `DavXmlElement` XML 表示与序列化边界;具体 XML crate 是 Forge 私有实现,产品不直接依赖。 - DAV error、multistatus/propstat、dead property、supportedlock/lockdiscovery 和 DeltaV version-tree 的 response grammar。 - `DavResourceBackend`、`DavPropertyBackend`、`DavLockBackend` 和可选 `DavVersionBackend` port。 From 34dbb49e253a0f7a860774a16151ce4d73d573c3 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Sat, 25 Jul 2026 04:30:41 +0800 Subject: [PATCH 07/11] refactor(aster_forge_webdav): replace split backend traits with unified filesystem port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `DavResourceBackend`, `DavPropertyBackend`, `DavLockBackend`, and `DavVersionBackend` with a single `DavFileSystem` trait and a `DavLockSystem` trait, eliminating the `DavBackend` aggregate bound - Introduce `DavFile`, `DavMetaData`, and `DavDirEntry` as object-safe traits in place of concrete `DavReadOutcome`, `DavResourceMetadata`, and `DavDirectoryEntry` structs - Add `FsError` enum with exhaustive `From for DavBackendError` conversion; add `FsResult`, `FsFuture`, `FsStream`, and `LsFuture` type aliases - Add `OpenOptions` value type with `read()` and `write()` constructors; add `ReadDirMeta` enum for directory listing metadata mode - Add `DavPropertyTarget` carrying resource kind and opaque product-side id for batched dead-property reads; replace `DavProperty`/`DavPropertyName`/`DavPropertyPatch`/`DavPropertyPatchOutcome` with flat `DavProp` and `(StatusCode, DavProp)` tuples - Extend `DavLockSystem` with `prepare_lock`, `check`, `discover_many`, `conflicting_locks`, and `delete`; add `DavLockPreflightError` and `DavLockError` error types; rename persistent lock state to `DavLock`, keep `DavLockInfo` as protocol response value - Add `get_props_many` and `get_props_many_for_targets` default methods on `DavFileSystem` with sequential fallback implementations - Update `lib.rs` re-exports to reflect the new public surface - Add `tests/backend.rs` covering exhaustive `FsError`→`DavBackendErrorKind` mapping and `OpenOptions`/`DavPropertyTarget` value construction - Update `docs/crates/aster_forge_webdav.md` to document the consolidated backend contract --- crates/aster_forge_webdav/src/backend.rs | 401 +++++++++++++-------- crates/aster_forge_webdav/src/lib.rs | 9 +- crates/aster_forge_webdav/tests/backend.rs | 47 +++ docs/crates/aster_forge_webdav.md | 4 +- 4 files changed, 313 insertions(+), 148 deletions(-) create mode 100644 crates/aster_forge_webdav/tests/backend.rs diff --git a/crates/aster_forge_webdav/src/backend.rs b/crates/aster_forge_webdav/src/backend.rs index 97e8286..28bbc87 100644 --- a/crates/aster_forge_webdav/src/backend.rs +++ b/crates/aster_forge_webdav/src/backend.rs @@ -1,19 +1,23 @@ -//! Product adapter ports consumed by a WebDAV protocol engine. +//! 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::Bytes; +use bytes::{Buf, Bytes}; use futures::Stream; +use http::StatusCode; -use crate::{DavPath, DavXmlElement, Depth}; +use crate::{DavPath, DavXmlElement}; /// Stream used for product-independent WebDAV content transfer. pub type DavContentStream = Pin> + Send + 'static>>; -/// Stable backend failure categories that the protocol layer can map to WebDAV responses. +/// Stable backend failure categories mapped by the protocol layer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DavBackendErrorKind { NotFound, @@ -32,211 +36,324 @@ pub enum DavBackendErrorKind { #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[error("WebDAV backend operation failed: {kind:?}")] pub struct DavBackendError { - /// Stable failure category. Product details stay in product logs and errors. pub kind: DavBackendErrorKind, } impl DavBackendError { - /// Creates a classified backend error. #[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)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DavResourceKind { File, Collection, } -/// Protocol-visible resource metadata supplied by the product adapter. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavResourceMetadata { +/// Opaque product-side identity used to batch dead-property reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DavPropertyTarget { pub kind: DavResourceKind, - pub content_length: u64, - pub content_type: Option, - pub etag: Option, - pub created_at: Option, - pub modified_at: Option, + pub id: i64, } -/// Protocol-visible state used to evaluate one resource referenced by a WebDAV `If` header. +/// 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 by the protocol layer while evaluating WebDAV `If` conditions. +/// Product adapter used while evaluating WebDAV `If` conditions. #[async_trait] pub trait DavIfStateResolver: Send + Sync { async fn resolve_if_state(&self, path: &DavPath) -> Result; } -/// One child returned by a collection listing. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavDirectoryEntry { - pub path: DavPath, - pub metadata: DavResourceMetadata, -} - -/// Result of opening resource content for a WebDAV response. -pub struct DavReadOutcome { - pub metadata: DavResourceMetadata, - pub content: DavContentStream, +/// Metadata loading mode for directory entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadDirMeta { + Data, } -/// Parameters for a WebDAV `PUT` operation. -pub struct DavWriteRequest { - pub path: DavPath, - pub content_length: Option, - pub content_type: Option, +/// 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, - pub overwrite: bool, - pub content: DavContentStream, } -/// Result of a successful `PUT` operation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavWriteOutcome { - pub created: bool, - pub metadata: DavResourceMetadata, -} +impl OpenOptions { + #[must_use] + pub fn read() -> Self { + Self { + read: true, + ..Self::default() + } + } -/// Resource operations that remain authoritative in the product adapter. -#[async_trait] -pub trait DavResourceBackend: Send + Sync { - async fn metadata(&self, path: &DavPath) -> Result; - async fn list( - &self, - path: &DavPath, - depth: Depth, - ) -> Result, DavBackendError>; - async fn read(&self, path: &DavPath) -> Result; - async fn write(&self, request: DavWriteRequest) -> Result; - async fn create_collection(&self, path: &DavPath) -> Result<(), DavBackendError>; - async fn delete(&self, path: &DavPath, depth: Depth) -> Result<(), DavBackendError>; - async fn copy( - &self, - source: &DavPath, - destination: &DavPath, - depth: Depth, - overwrite: bool, - ) -> Result<(), DavBackendError>; - async fn move_resource( - &self, - source: &DavPath, - destination: &DavPath, - overwrite: bool, - ) -> Result<(), DavBackendError>; - async fn quota(&self) -> Result<(u64, Option), DavBackendError>; + #[must_use] + pub fn write() -> Self { + Self { + write: true, + ..Self::default() + } + } } -/// Expanded DAV property name. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct DavPropertyName { - pub namespace: Option, - pub local_name: String, +/// 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 property_target(&self) -> Option { + None + } + fn is_empty(&self) -> bool { + self.len() == 0 + } + fn is_file(&self) -> bool { + !self.is_dir() + } } -/// Stored dead-property value. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavProperty { - pub name: DavPropertyName, - pub xml: Option, +/// One directory entry returned by a product adapter. +pub trait DavDirEntry: Send { + fn name(&self) -> Vec; + fn metadata<'a>(&'a self) -> FsFuture<'a, Box>; } -/// One property set/remove mutation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavPropertyPatch { - pub remove: bool, - pub property: DavProperty, +/// 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<'_, ()>; } -/// Result of one property mutation. +/// Stored dead property exchanged with the product adapter. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavPropertyPatchOutcome { - pub name: DavPropertyName, - pub status: http::StatusCode, +pub struct DavProp { + pub name: String, + pub prefix: Option, + pub namespace: Option, + pub xml: Option>, } -/// Dead-property persistence supplied by the product adapter. -#[async_trait] -pub trait DavPropertyBackend: Send + Sync { - async fn properties( - &self, - path: &DavPath, - include_values: bool, - ) -> Result, DavBackendError>; - async fn patch_properties( - &self, - path: &DavPath, - patches: Vec, - ) -> Result, DavBackendError>; -} +/// 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, ()>; -/// Parameters for acquiring a WebDAV lock. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavLockRequest { - pub path: DavPath, - pub owner_xml: Option, - pub timeout: Option, - pub shared: bool, - pub deep: bool, + 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()) }) + } + + 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 get_props_many_for_targets<'a>( + &'a self, + targets: &'a [(DavPath, DavPropertyTarget)], + do_content: bool, + ) -> FsFuture<'a, HashMap>> { + Box::pin(async move { + let paths = targets + .iter() + .map(|(path, _)| path.clone()) + .collect::>(); + self.get_props_many(&paths, do_content).await + }) + } + + 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 supplied by the product adapter. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavLockInfo { +/// Protocol-visible lock state persisted by the product adapter. +#[derive(Debug, Clone)] +pub struct DavLock { pub token: String, - pub path: DavPath, - pub owner_xml: Option, + pub path: Box, + pub principal: Option, + pub owner: Option>, pub timeout_at: Option, pub timeout: Option, pub shared: bool, pub deep: bool, } -/// Lock persistence supplied by the product adapter. -#[async_trait] -pub trait DavLockBackend: Send + Sync { - async fn acquire(&self, request: DavLockRequest) -> Result; - async fn refresh( +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(DavLock), + 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<(), ()>>; + fn refresh( &self, path: &DavPath, token: &str, timeout: Option, - ) -> Result; - async fn release(&self, path: &DavPath, token: &str) -> Result<(), DavBackendError>; - async fn discover(&self, path: &DavPath) -> Result, DavBackendError>; - async fn check_write( + ) -> LsFuture<'_, Result>; + fn check( &self, path: &DavPath, + principal: Option<&str>, + ignore_principal: bool, deep: bool, submitted_tokens: &[String], - ) -> Result<(), DavBackendError>; + ) -> 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<(), ()>>; } -/// One protocol-visible resource version. +/// Lock value used by protocol response composition. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavVersionInfo { - pub version_id: String, - pub href: String, - pub created_at: Option, - pub etag: Option, -} - -/// Optional DeltaV capability supplied by the product adapter. -#[async_trait] -pub trait DavVersionBackend: Send + Sync { - async fn versions(&self, path: &DavPath) -> Result, DavBackendError>; - async fn enable_version_control(&self, path: &DavPath) -> Result<(), DavBackendError>; +pub struct DavLockInfo { + pub token: String, + pub path: DavPath, + pub owner_xml: Option, + pub timeout_at: Option, + pub timeout: Option, + pub shared: bool, + pub deep: bool, } - -/// Aggregate capability boundary required by a complete WebDAV protocol engine. -pub trait DavBackend: DavResourceBackend + DavPropertyBackend + DavLockBackend {} - -impl DavBackend for T where T: DavResourceBackend + DavPropertyBackend + DavLockBackend {} diff --git a/crates/aster_forge_webdav/src/lib.rs b/crates/aster_forge_webdav/src/lib.rs index ea7a593..9fdee48 100644 --- a/crates/aster_forge_webdav/src/lib.rs +++ b/crates/aster_forge_webdav/src/lib.rs @@ -33,11 +33,10 @@ pub mod xml; pub mod xml_response; pub use backend::{ - DavBackend, DavBackendError, DavBackendErrorKind, DavContentStream, DavDirectoryEntry, - DavIfResourceState, DavIfStateResolver, DavLockBackend, DavLockInfo, DavLockRequest, - DavProperty, DavPropertyBackend, DavPropertyName, DavPropertyPatch, DavPropertyPatchOutcome, - DavReadOutcome, DavResourceBackend, DavResourceKind, DavResourceMetadata, DavVersionBackend, - DavVersionInfo, DavWriteOutcome, DavWriteRequest, + DavBackendError, DavBackendErrorKind, DavContentStream, DavDirEntry, DavFile, DavFileSystem, + DavIfResourceState, DavIfStateResolver, DavLock, DavLockError, DavLockInfo, + DavLockPreflightError, DavLockSystem, DavMetaData, DavProp, DavPropertyTarget, DavResourceKind, + FsError, FsFuture, FsResult, FsStream, LsFuture, OpenOptions, ReadDirMeta, }; pub use deltav::{ DavVersionTreeReportError, validate_version_tree_report, version_control_response, diff --git a/crates/aster_forge_webdav/tests/backend.rs b/crates/aster_forge_webdav/tests/backend.rs new file mode 100644 index 0000000..c00a334 --- /dev/null +++ b/crates/aster_forge_webdav/tests/backend.rs @@ -0,0 +1,47 @@ +use aster_forge_webdav::{ + DavBackendError, DavBackendErrorKind, DavPropertyTarget, DavResourceKind, 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_and_property_targets_are_transport_neutral_values() { + assert_eq!( + OpenOptions::read(), + OpenOptions { + read: true, + ..OpenOptions::default() + } + ); + assert_eq!( + OpenOptions::write(), + OpenOptions { + write: true, + ..OpenOptions::default() + } + ); + + let target = DavPropertyTarget { + kind: DavResourceKind::Collection, + id: i64::MAX, + }; + assert_eq!(target.kind, DavResourceKind::Collection); + assert_eq!(target.id, i64::MAX); +} diff --git a/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md index eb2ead9..edd2462 100644 --- a/docs/crates/aster_forge_webdav.md +++ b/docs/crates/aster_forge_webdav.md @@ -33,7 +33,9 @@ Forge 负责: - DeltaV `DAV:version-tree` REPORT 选择、file-only/unsupported mapping、version multistatus 和 VERSION-CONTROL response selection。 - `DavXmlElement` XML 表示与序列化边界;具体 XML crate 是 Forge 私有实现,产品不直接依赖。 - DAV error、multistatus/propstat、dead property、supportedlock/lockdiscovery 和 DeltaV version-tree 的 response grammar。 -- `DavResourceBackend`、`DavPropertyBackend`、`DavLockBackend` 和可选 `DavVersionBackend` port。 +- 唯一 backend contract:`DavFileSystem`、`DavMetaData`、`DavFile`、`DavDirEntry`、 + `DavLockSystem`、`FsError` 和 `OpenOptions`;产品只实现这些 Forge port,不再复制协议 trait。 +- `DavPropertyTarget` 只携带资源种类与产品侧不透明 ID,用于批量 dead-property 读取;Forge 不解释数据库身份。 - Actix transport 与 transport-neutral `http` 类型的显式转换。 - OPTIONS、405、body-policy failure 和 download response 的 product-neutral response shell。 From cbb1cf3bfb31e5640c3fc788f474207ed547d8aa Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Sat, 25 Jul 2026 07:15:28 +0800 Subject: [PATCH 08/11] refactor(aster_forge_webdav,aster_forge_xml): migrate XML handling from xmltree to aster_forge_xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace xmltree dependency with aster_forge_xml for bounded parsing, streaming writing, and safety enforcement. **XML Boundary Migration:** - Replace `xmltree::Element` with `aster_forge_xml::{BorrowedDocument, OwnedDocument, ElementRef, NodeRef}` - Add `element_from_forge` to convert forge element refs to owned `DavXmlElement` trees - Implement streaming `write_element` using `XmlStreamWriter` with namespace inheritance tracking - Preserve whitespace trimming policy via `ParseOptions::trim_whitespace(true)` **Safety and Error Mapping:** - Map all `XmlSafetyError` variants (InputTooLarge, TooManyElements, etc.) to `DavXmlError` - Add `map_forge_xml_error` to unify `ForgeXmlError` → `DavXmlError` conversion - Enhance ENTITY detection in `aster_forge_xml` parser to reject ``, `--`, `?>`) - Add depth boundary tests (exact `DEFAULT_XML_MAX_DEPTH`, depth+1) - Add namespace shadowing, undeclaration, and attribute prefix tests - Enhance ENTITY rejection tests (in comments, bare ` for DavXmlError { match error { XmlSafetyError::ExternalEntity => Self::ExternalEntity, XmlSafetyError::TooDeep => Self::TooDeep, - XmlSafetyError::Malformed | XmlSafetyError::InvalidPolicy => Self::Malformed, + XmlSafetyError::InvalidPolicy + | XmlSafetyError::InputTooLarge + | XmlSafetyError::OutputTooLarge + | XmlSafetyError::TooManyElements + | XmlSafetyError::TooManyAttributes + | XmlSafetyError::TextTooLarge + | XmlSafetyError::TooManyEvents + | XmlSafetyError::InvalidEncoding + | XmlSafetyError::Malformed => Self::Malformed, } } } @@ -103,21 +113,18 @@ impl DavXmlElement { } /// Parses one bounded XML element from a reader. - pub fn parse_reader(mut reader: impl Read) -> Result { - let mut bytes = Vec::new(); - reader - .read_to_end(&mut bytes) - .map_err(|_| DavXmlError::Malformed)?; - Self::parse(&bytes) + 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 bytes = Vec::new(); - element_to_xmltree(self) - .write(&mut bytes) - .map_err(|_| DavXmlError::Malformed)?; - Ok(bytes) + 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. @@ -359,9 +366,9 @@ pub fn parse_report_root(body: &[u8]) -> Result Result { validate_xml_input(bytes, XmlSafetyPolicy::untrusted())?; - Element::parse(Cursor::new(bytes)) - .map(element_from_xmltree) - .map_err(|_| DavXmlError::Malformed) + let document = BorrowedDocument::parse_with_options(bytes, &webdav_parse_options()) + .map_err(map_forge_xml_error)?; + Ok(element_from_forge(document.root())) } fn is_dav_element(element: &DavXmlElement, local_name: &str) -> bool { @@ -384,61 +391,168 @@ fn xml_lang_value(element: &DavXmlElement) -> Option<&str> { .map(String::as_str) } -fn element_from_xmltree(element: Element) -> DavXmlElement { +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().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, - prefix: element.prefix, - namespace: element.namespace, - namespaces: element - .namespaces - .map(|namespaces| { - namespaces - .iter() - .map(|(prefix, namespace)| (prefix.to_owned(), namespace.to_owned())) - .collect() - }) - .unwrap_or_default(), - attributes: element.attributes.into_iter().collect(), + name: element.name().to_owned(), + prefix: element.prefix().map(str::to_owned), + namespace: element.namespace().map(str::to_owned), + namespaces, + attributes, children: element - .children - .into_iter() + .children() .map(|child| match child { - XMLNode::Element(element) => DavXmlNode::Element(element_from_xmltree(element)), - XMLNode::Text(text) => DavXmlNode::Text(text), - XMLNode::CData(text) => DavXmlNode::CData(text), - XMLNode::Comment(text) => DavXmlNode::Comment(text), - XMLNode::ProcessingInstruction(name, value) => { - DavXmlNode::ProcessingInstruction(name, value) - } + 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 element_to_xmltree(element: &DavXmlElement) -> Element { - let mut result = Element::new(&element.name); - result.prefix.clone_from(&element.prefix); - result.namespace.clone_from(&element.namespace); - if !element.namespaces.is_empty() { - let mut namespaces = xmltree::Namespace::empty(); - for (prefix, namespace) in &element.namespaces { - namespaces.force_put(prefix.clone(), namespace.clone()); +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()); + } } - result.namespaces = Some(namespaces); + namespaces.insert(String::new(), String::new()); } - result.attributes.extend(element.attributes.clone()); - result.children = element - .children + + let write_attributes = attributes .iter() - .map(|child| match child { - DavXmlNode::Element(element) => XMLNode::Element(element_to_xmltree(element)), - DavXmlNode::Text(text) => XMLNode::Text(text.clone()), - DavXmlNode::CData(text) => XMLNode::CData(text.clone()), - DavXmlNode::Comment(text) => XMLNode::Comment(text.clone()), - DavXmlNode::ProcessingInstruction(name, value) => { - XMLNode::ProcessingInstruction(name.clone(), value.clone()) + .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)?; } - }) - .collect(); - result + } + } + 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/tests/xml.rs b/crates/aster_forge_webdav/tests/xml.rs index 878cce1..b9643b6 100644 --- a/crates/aster_forge_webdav/tests/xml.rs +++ b/crates/aster_forge_webdav/tests/xml.rs @@ -1,8 +1,18 @@ -use aster_forge_utils::xml::DEFAULT_XML_MAX_DEPTH; +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() { @@ -269,6 +279,108 @@ fn xml_boundary_round_trips_namespaces_comments_cdata_utf8_and_escaping() { ); } +#[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) + ); + + 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::Malformed) + ); +} + +#[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"); diff --git a/crates/aster_forge_xml/src/parser.rs b/crates/aster_forge_xml/src/parser.rs index c50b11d..6339edc 100644 --- a/crates/aster_forge_xml/src/parser.rs +++ b/crates/aster_forge_xml/src/parser.rs @@ -240,7 +240,22 @@ 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()); + if options.safety.reject_doctype + && matches!( + error, + quick_xml::Error::Syntax(quick_xml::errors::SyntaxError::InvalidBangMarkup) + ) + && bytes + .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] diff --git a/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md index edd2462..95ee241 100644 --- a/docs/crates/aster_forge_webdav.md +++ b/docs/crates/aster_forge_webdav.md @@ -31,7 +31,7 @@ Forge 负责: - 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。 -- `DavXmlElement` XML 表示与序列化边界;具体 XML crate 是 Forge 私有实现,产品不直接依赖。 +- `DavXmlElement` XML 表示与序列化边界;解析、安全限制和流式写出由 `aster_forge_xml` 承担,产品不直接依赖具体 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。 @@ -62,8 +62,8 @@ Forge 负责: ## 测试要求 - 协议 crate 测试路径逃逸、header grammar、同源 `Destination`、条件请求和 request-head 解析。 -- XML 边界矩阵覆盖空体、QName 冲突、未知子树、重复/互斥控制、DTD/ENTITY、深度临界、UTF-8、转义和大属性值。 -- XML response 矩阵覆盖状态行、元素顺序、QName、命名空间声明、锁字段、死属性重建和异常旧值转义。 +- 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 的组合结果。 From c8a83aea0226a2e81a136c69d55903b44a4fc941 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Sat, 25 Jul 2026 09:50:06 +0800 Subject: [PATCH 09/11] feat(webdav,xml): add unified guard ports and actix adapters ## Summary Consolidate If-header evaluation, lock enforcement, and parent validation into unified ports with Actix transport adapters. Parse known request grammars directly from source-backed XML arena without materializing intermediate DOM. ## Changes ### WebDAV Protocol Layer - Add `enforce_if_header_with_backends` to resolve ETag/lock state from canonical filesystem and lock system ports - Add `enforce_unlocked`, `enforce_parent_unlocked` to reject operations when conflicting lock tokens were not submitted for lock root - Add `unsubmitted_lock_conflicts` to filter locks whose tokens were not provided - Add `enforce_parent_collection` to require mutation target parent exists as collection - Add `ensure_lock_target_exists` to create empty lock-null file when allowed - Add `replace_relative_prefix` for recursive mutation path rebasing - Add `lock_discovery_element` to build DAV:lockdiscovery property from backend locks - Move `DavLockInfo` fields into `DavLock` backend type (path, owner, timeouts) - Add `DavEvent::completed` to build transport-neutral event from request head, deliberately excluding sensitive If tokens, credentials, and request bodies ### Actix Transport Adapters - Add `protocol_error_response`, `converted_headers` for unified error mapping - Add `enforce_if_header_with_backends`, `enforce_unlocked`, `enforce_parent_unlocked` Actix wrappers that map protocol/backend errors to HttpResponse - Add `evaluate_http_etag_preconditions` adapter for ETag precondition evaluation ### XML Parser Optimization - Parse PROPFIND, PROPPATCH, LOCK request grammars directly from `aster_forge_xml` source-backed arena without validation pass or full DOM copy - Materialize `DavXmlElement` only for owner/property subtrees crossing backend boundary - Consolidate ENTITY declaration detection in `map_quick_xml_error_at` across document parser, streaming reader, and validator entry points - Extract common `utf8` and error mapping to `syntax` module ### Tests - Add 603-line guard.rs with fake backends covering ETag + lock token resolution, tagged lock root matching, parent lock enforcement, parent collection validation, lock-null file creation, and metadata/open/flush error propagation - Add actix.rs adapter tests for header conversion and precondition evaluation - Add event.rs test confirming completed event excludes sensitive lock tokens - Add xml.rs test ensuring consistent ENTITY classification across all entry points - Add resource.rs test for recursive path rebasing ### Documentation - Update aster_forge_webdav.md to reflect unified guard ports, direct XML arena traversal, Actix adapter coverage, and event sanitization contract --- crates/aster_forge_webdav/src/actix.rs | 101 +++- crates/aster_forge_webdav/src/backend.rs | 12 - crates/aster_forge_webdav/src/event.rs | 28 +- crates/aster_forge_webdav/src/lib.rs | 28 +- crates/aster_forge_webdav/src/lock.rs | 142 ++++- crates/aster_forge_webdav/src/protocol.rs | 62 +- crates/aster_forge_webdav/src/resource.rs | 61 +- crates/aster_forge_webdav/src/xml.rs | 84 +-- crates/aster_forge_webdav/tests/actix.rs | 36 +- crates/aster_forge_webdav/tests/event.rs | 63 +- crates/aster_forge_webdav/tests/guard.rs | 603 ++++++++++++++++++++ crates/aster_forge_webdav/tests/lock.rs | 22 +- crates/aster_forge_webdav/tests/resource.rs | 25 +- crates/aster_forge_xml/src/document.rs | 15 +- crates/aster_forge_xml/src/parser.rs | 18 +- crates/aster_forge_xml/src/stream.rs | 13 +- crates/aster_forge_xml/src/syntax.rs | 21 + crates/aster_forge_xml/tests/xml.rs | 45 +- docs/crates/aster_forge_webdav.md | 7 +- 19 files changed, 1271 insertions(+), 115 deletions(-) create mode 100644 crates/aster_forge_webdav/tests/guard.rs diff --git a/crates/aster_forge_webdav/src/actix.rs b/crates/aster_forge_webdav/src/actix.rs index def9d37..db7451a 100644 --- a/crates/aster_forge_webdav/src/actix.rs +++ b/crates/aster_forge_webdav/src/actix.rs @@ -7,8 +7,9 @@ use http::{HeaderMap, HeaderName, HeaderValue, Uri}; use crate::protocol::DavProtocolError; use crate::{ - DavBodyError, DavBodyPolicy, DavMethod, DavRequestHead, DavRequestOrigin, DavResponse, - DavResponseBody, + DavBodyError, DavBodyPolicy, DavFileSystem, DavIfEvaluationError, DavLockSystem, DavMethod, + DavPath, DavPrecondition, DavRequestHead, DavRequestOrigin, DavResponse, DavResponseBody, + IfHeader, }; /// Request body prepared according to the selected WebDAV method contract. @@ -74,6 +75,102 @@ pub fn into_response(response: DavResponse) -> HttpResponse { } } +/// 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()); diff --git a/crates/aster_forge_webdav/src/backend.rs b/crates/aster_forge_webdav/src/backend.rs index 28bbc87..98a67c8 100644 --- a/crates/aster_forge_webdav/src/backend.rs +++ b/crates/aster_forge_webdav/src/backend.rs @@ -345,15 +345,3 @@ pub trait DavLockSystem: Send + Sync { fn conflicting_locks(&self, path: &DavPath, deep: bool) -> LsFuture<'_, Vec>; fn delete(&self, path: &DavPath) -> LsFuture<'_, Result<(), ()>>; } - -/// Lock value used by protocol response composition. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavLockInfo { - pub token: String, - pub path: DavPath, - pub owner_xml: Option, - pub timeout_at: Option, - pub timeout: Option, - pub shared: bool, - pub deep: bool, -} diff --git a/crates/aster_forge_webdav/src/event.rs b/crates/aster_forge_webdav/src/event.rs index 5920b4c..2f5ae80 100644 --- a/crates/aster_forge_webdav/src/event.rs +++ b/crates/aster_forge_webdav/src/event.rs @@ -2,7 +2,7 @@ use std::time::Duration; -use crate::{DavBackendErrorKind, DavPath}; +use crate::{DavBackendErrorKind, DavPath, DavRequestHead}; /// Protocol operations exposed to event observers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -72,6 +72,32 @@ pub struct DavEvent { 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 diff --git a/crates/aster_forge_webdav/src/lib.rs b/crates/aster_forge_webdav/src/lib.rs index 9fdee48..7ded478 100644 --- a/crates/aster_forge_webdav/src/lib.rs +++ b/crates/aster_forge_webdav/src/lib.rs @@ -34,9 +34,9 @@ pub mod xml_response; pub use backend::{ DavBackendError, DavBackendErrorKind, DavContentStream, DavDirEntry, DavFile, DavFileSystem, - DavIfResourceState, DavIfStateResolver, DavLock, DavLockError, DavLockInfo, - DavLockPreflightError, DavLockSystem, DavMetaData, DavProp, DavPropertyTarget, DavResourceKind, - FsError, FsFuture, FsResult, FsStream, LsFuture, OpenOptions, ReadDirMeta, + DavIfResourceState, DavIfStateResolver, DavLock, DavLockError, DavLockPreflightError, + DavLockSystem, DavMetaData, DavProp, DavPropertyTarget, DavResourceKind, FsError, FsFuture, + FsResult, FsStream, LsFuture, OpenOptions, ReadDirMeta, }; pub use deltav::{ DavVersionTreeReportError, validate_version_tree_report, version_control_response, @@ -44,9 +44,11 @@ pub use deltav::{ }; pub use event::{DavEvent, DavEventOutcome, DavEventSink, DavOperation, NoopDavEventSink}; pub use lock::{ - DavLockPlan, DavLockPlanError, lock_acquire_success_response, lock_conflict_response, - lock_limit_response, lock_refresh_success_response, lock_xml_error_response, plan_lock_request, - unlock_success_response, unlock_token_mismatch_response, + 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, @@ -60,10 +62,11 @@ pub use property::{ pub use protocol::{ DavIfEvaluationError, DavPrecondition, DavProtocolError, DavProtocolErrorKind, Depth, Destination, IfHeader, IfResourceGroup, IfStateCondition, IfStateList, - destination_relative_path, enforce_if_header, 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, + 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, @@ -73,8 +76,9 @@ pub use request::{DavBodyPolicy, DavMethod, DavRequestHead, DavRequestOrigin}; pub use resource::{ DavCopyMoveMethod, DavCopyMovePlan, DavMutationFailure, DavMutationPlanError, DavMutationResponseError, collection_created_response, delete_success_response, - is_descendant_path, mutation_multistatus_response, mutation_plan_error_response, - mutation_success_response, plan_copy_move_request, resource_identity_path, same_resource_path, + 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::{ diff --git a/crates/aster_forge_webdav/src/lock.rs b/crates/aster_forge_webdav/src/lock.rs index 67453fb..008a2e7 100644 --- a/crates/aster_forge_webdav/src/lock.rs +++ b/crates/aster_forge_webdav/src/lock.rs @@ -5,11 +5,14 @@ use std::time::Duration; use http::header::{CACHE_CONTROL, CONTENT_TYPE}; use http::{HeaderMap, HeaderValue, StatusCode}; +use crate::DavLockSystem; use crate::response::xml_request_error_response; use crate::{ - DavErrorCondition, DavLockInfo, DavLockXml, DavPath, DavProtocolError, DavRequestHead, - DavResponse, DavXmlElement, DavXmlError, dav_error_element, dav_lock_response_element, - href_for_dav_path, parse_lock_request, parse_lock_timeout, submitted_lock_tokens, + 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. @@ -36,6 +39,114 @@ pub enum DavLockPlanError { 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, @@ -80,14 +191,14 @@ pub fn plan_lock_request( } fn lock_success_response( - lock: &DavLockInfo, + 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_xml.clone(), + owner: lock.owner.as_deref().cloned(), timeout: lock.timeout, shared: lock.shared, deep: lock.deep, @@ -109,7 +220,7 @@ fn lock_success_response( /// Builds the successful response for a LOCK refresh. pub fn lock_refresh_success_response( - lock: &DavLockInfo, + lock: &DavLock, prefix: &str, ) -> Result { lock_success_response(lock, StatusCode::OK, prefix, false) @@ -117,7 +228,7 @@ pub fn lock_refresh_success_response( /// Builds the 200/201 response for a LOCK acquisition. pub fn lock_acquire_success_response( - lock: &DavLockInfo, + lock: &DavLock, prefix: &str, resource_existed: bool, ) -> Result { @@ -133,6 +244,23 @@ pub fn lock_acquire_success_response( ) } +/// 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( diff --git a/crates/aster_forge_webdav/src/protocol.rs b/crates/aster_forge_webdav/src/protocol.rs index 7de4783..dfa45f7 100644 --- a/crates/aster_forge_webdav/src/protocol.rs +++ b/crates/aster_forge_webdav/src/protocol.rs @@ -5,8 +5,12 @@ use std::time::{Duration, SystemTime}; use http::header::{self, HeaderMap, HeaderValue}; use http::{StatusCode, Uri}; -use crate::{DavBackendError, DavIfResourceState, DavIfStateResolver, DavPath}; +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)] @@ -303,6 +307,62 @@ pub async fn enforce_if_header( Ok(()) } +/// 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 } => { diff --git a/crates/aster_forge_webdav/src/resource.rs b/crates/aster_forge_webdav/src/resource.rs index 7948063..3324e0b 100644 --- a/crates/aster_forge_webdav/src/resource.rs +++ b/crates/aster_forge_webdav/src/resource.rs @@ -4,8 +4,9 @@ use http::header::{CACHE_CONTROL, CONTENT_LOCATION, CONTENT_TYPE}; use http::{HeaderValue, StatusCode}; use crate::{ - DavErrorCondition, DavMultiStatusItem, DavPath, DavResourceKind, DavResponse, DavXmlError, - Depth, dav_multistatus_element, href_for_dav_path, + 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. @@ -46,6 +47,39 @@ pub fn validate_collection_create_target(path: &str) -> Result<(), DavMutationPl } } +/// 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")] @@ -186,6 +220,29 @@ pub fn is_descendant_path(parent: &str, child: &str) -> bool { 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 { diff --git a/crates/aster_forge_webdav/src/xml.rs b/crates/aster_forge_webdav/src/xml.rs index 9eb8752..f3cd5b9 100644 --- a/crates/aster_forge_webdav/src/xml.rs +++ b/crates/aster_forge_webdav/src/xml.rs @@ -8,7 +8,7 @@ use std::io::Read; use aster_forge_xml::{ BorrowedDocument, ElementRef, Error as ForgeXmlError, NodeRef, OwnedDocument, ParseOptions, - XmlSafetyError, XmlSafetyPolicy, XmlStreamWriter, XmlWriteAttribute, validate_xml_input, + XmlSafetyError, XmlStreamWriter, XmlWriteAttribute, }; const DAV_NAMESPACE: &str = "DAV:"; @@ -63,7 +63,10 @@ pub enum DavXmlNode { ProcessingInstruction(String, Option), } -/// XML element whose concrete parser/serializer is private to AsterForge. +/// 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. @@ -216,8 +219,9 @@ pub fn parse_propfind_request(body: &[u8]) -> Result Result Result, DavXmlError> { - let root = parse_element(body)?; - if !is_dav_element(&root, "propertyupdate") { + 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 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") { @@ -280,15 +285,15 @@ pub fn parse_proppatch_request(body: &[u8]) -> Result>(); - if !matches!(dav_children.as_slice(), [child] if is_dav_element(child, "prop")) { + 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 = property.clone(); + 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 @@ -315,8 +320,9 @@ pub fn parse_proppatch_request(body: &[u8]) -> Result Result { - let root = parse_element(body)?; - if !is_dav_element(&root, "lockinfo") { + let document = parse_document(body)?; + let root = document.root(); + if !is_dav_element(root, "lockinfo") { return Err(DavXmlError::InvalidGrammar); } let mut shared = None; @@ -329,11 +335,11 @@ pub fn parse_lock_request(body: &[u8]) -> Result>(); shared = match children.as_slice() { - [scope] if is_dav_element(scope, "exclusive") => Some(false), - [scope] if is_dav_element(scope, "shared") => Some(true), + [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") { @@ -342,14 +348,17 @@ pub fn parse_lock_request(body: &[u8]) -> Result>(); - if !matches!(children.as_slice(), [kind] if is_dav_element(kind, "write")) { + 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") && owner.replace(child.clone()).is_some() { - return Err(DavXmlError::InvalidGrammar); + } 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) { @@ -360,35 +369,40 @@ pub fn parse_lock_request(body: &[u8]) -> Result Result { - let root = parse_element(body)?; - Ok(requested_property(&root)) + let document = parse_document(body)?; + Ok(requested_property(document.root())) } fn parse_element(bytes: &[u8]) -> Result { - validate_xml_input(bytes, XmlSafetyPolicy::untrusted())?; - let document = BorrowedDocument::parse_with_options(bytes, &webdav_parse_options()) - .map_err(map_forge_xml_error)?; + let document = parse_document(bytes)?; Ok(element_from_forge(document.root())) } -fn is_dav_element(element: &DavXmlElement, local_name: &str) -> bool { - element.name == local_name && element.namespace.as_deref() == Some(DAV_NAMESPACE) +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 requested_property(element: &DavXmlElement) -> DavRequestedProperty { +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.clone(), - namespace: element.namespace.clone(), - prefix: element.prefix.clone(), + name: element.name().to_owned(), + namespace: element.namespace().map(str::to_owned), + prefix: element.prefix().map(str::to_owned), } } -fn xml_lang_value(element: &DavXmlElement) -> Option<&str> { +fn xml_lang_value<'document, S: AsRef<[u8]>>( + element: ElementRef<'document, S>, +) -> Option<&'document str> { element - .attributes - .get("xml:lang") - .or_else(|| element.attributes.get("lang")) - .map(String::as_str) + .attribute("xml:lang") + .or_else(|| element.attribute("lang")) } fn webdav_parse_options() -> ParseOptions { diff --git a/crates/aster_forge_webdav/tests/actix.rs b/crates/aster_forge_webdav/tests/actix.rs index 3cb0878..1941f44 100644 --- a/crates/aster_forge_webdav/tests/actix.rs +++ b/crates/aster_forge_webdav/tests/actix.rs @@ -1,7 +1,8 @@ #![cfg(feature = "actix")] +use actix_web::http::StatusCode; use actix_web::{FromRequest, web}; -use aster_forge_webdav::{DavBodyError, DavMethod}; +use aster_forge_webdav::{DavBodyError, DavMethod, DavPrecondition}; use bytes::Bytes; use futures::StreamExt; @@ -71,3 +72,36 @@ async fn method_body_preparation_collects_xml_rejects_empty_policy_and_preserves 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/event.rs b/crates/aster_forge_webdav/tests/event.rs index dbb7bdd..f61028f 100644 --- a/crates/aster_forge_webdav/tests/event.rs +++ b/crates/aster_forge_webdav/tests/event.rs @@ -1,4 +1,10 @@ -use aster_forge_webdav::{DavBackendErrorKind, DavEventOutcome}; +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() { @@ -25,3 +31,58 @@ 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..59a8d98 --- /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<(), ()>> { + Box::pin(async { Err(()) }) + } + + fn refresh( + &self, + _path: &DavPath, + _token: &str, + _timeout: Option, + ) -> LsFuture<'_, Result> { + Box::pin(async { Err(()) }) + } + + 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<(), ()>> { + 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 index 1a23408..f0e6ae5 100644 --- a/crates/aster_forge_webdav/tests/lock.rs +++ b/crates/aster_forge_webdav/tests/lock.rs @@ -1,11 +1,11 @@ use std::time::Duration; use aster_forge_webdav::{ - DavLockInfo, DavLockPlan, DavLockPlanError, DavMethod, DavPath, DavProtocolErrorKind, + DavLock, DavLockPlan, DavLockPlanError, DavMethod, DavPath, DavProtocolErrorKind, DavRequestHead, DavRequestOrigin, DavResponseBody, Depth, IfHeader, - lock_acquire_success_response, lock_conflict_response, lock_limit_response, - lock_refresh_success_response, lock_xml_error_response, parse_if_header, plan_lock_request, - unlock_success_response, unlock_token_mismatch_response, + 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}; @@ -87,10 +87,11 @@ fn empty_lock_body_requires_exactly_one_scoped_refresh_token() { #[test] fn lock_success_responses_own_status_xml_and_lock_token_header_contract() { - let lock = DavLockInfo { + let lock = DavLock { token: "urn:uuid:lock".to_string(), - path: DavPath::new("/a.txt").expect("path"), - owner_xml: None, + path: Box::new(DavPath::new("/a.txt").expect("path")), + principal: None, + owner: None, timeout_at: None, timeout: Some(Duration::from_secs(60)), shared: false, @@ -119,6 +120,13 @@ fn lock_success_responses_own_status_xml_and_lock_token_header_contract() { 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] diff --git a/crates/aster_forge_webdav/tests/resource.rs b/crates/aster_forge_webdav/tests/resource.rs index dd4e8d6..702fd54 100644 --- a/crates/aster_forge_webdav/tests/resource.rs +++ b/crates/aster_forge_webdav/tests/resource.rs @@ -2,8 +2,9 @@ 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, resource_identity_path, same_resource_path, - validate_collection_create_target, validate_delete_target, + 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; @@ -19,6 +20,26 @@ fn resource_identity_and_descendant_rules_use_path_boundaries() { 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); 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 6339edc..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}; @@ -242,19 +242,7 @@ fn scan_xml(bytes: &[u8], options: &ParseOptions) -> Result, Erro loop { let event = reader.read_event().map_err(|error| { let error_position = usize::try_from(reader.error_position()).unwrap_or(bytes.len()); - if options.safety.reject_doctype - && matches!( - error, - quick_xml::Error::Syntax(quick_xml::errors::SyntaxError::InvalidBangMarkup) - ) - && bytes - .get(error_position..) - .is_some_and(|input| input.starts_with(b"( })) } -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..5fdf6ce 100644 --- a/crates/aster_forge_xml/src/syntax.rs +++ b/crates/aster_forge_xml/src/syntax.rs @@ -80,3 +80,24 @@ pub(crate) fn map_quick_xml_error(error: quick_xml::Error) -> Error { error => Error::InvalidXml(error.to_string()), } } + +pub(crate) fn map_quick_xml_error_at( + error: quick_xml::Error, + error_position: usize, + input: &[u8], + reject_doctype: bool, +) -> 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""; + + 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] fn applies_depth_and_element_limits_to_start_and_empty_elements() { let base = XmlSafetyPolicy::untrusted(); diff --git a/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md index 95ee241..5c5fa5a 100644 --- a/docs/crates/aster_forge_webdav.md +++ b/docs/crates/aster_forge_webdav.md @@ -20,6 +20,7 @@ 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。 @@ -27,16 +28,19 @@ Forge 负责: - HTTP ETag、`If-Modified-Since`、`If-Unmodified-Since` 的协议优先级。 - GET/HEAD 的 200/206/304/416 response planning、单段 byte range 选择与读取区间。 - `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。 -- `DavXmlElement` XML 表示与序列化边界;解析、安全限制和流式写出由 `aster_forge_xml` 承担,产品不直接依赖具体 XML 实现。 +- 已知 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。 - `DavPropertyTarget` 只携带资源种类与产品侧不透明 ID,用于批量 dead-property 读取;Forge 不解释数据库身份。 - Actix transport 与 transport-neutral `http` 类型的显式转换。 +- Actix adapter 统一完成 header conversion、协议/后端错误响应和 HTTP ETag/`If` guard 映射。 - OPTIONS、405、body-policy failure 和 download response 的 product-neutral response shell。 产品负责: @@ -62,6 +66,7 @@ Forge 负责: ## 测试要求 - 协议 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 和客户端集成测试。 From 0ab7f82eccb092bd6aac03510ce5567a9d091727 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Sat, 25 Jul 2026 11:02:27 +0800 Subject: [PATCH 10/11] refactor(webdav)!: remove urlencoding, fix protocol edge cases, add If-Range - Remove urlencoding dependency; use percent-encoding directly for paths - Fix If header evaluation: use OR between tagged resource groups - Add If-Range validator support for conditional range requests - Fix Range header: accept case-insensitive unit, trim leading whitespace - Fix origin comparison: validate authority with port defaults and reject userinfo - Fix lock timeout: clamp oversized values instead of rejecting - Change DavLockError::Conflict to Box to reduce enum size - Add DavLockError::TokenMismatch variant - Change unlock/refresh/delete signatures to return DavLockError - Fix child_relative_path: reject separator characters, return Result - Add InvalidChildName and map TooLarge to PAYLOAD_TOO_LARGE responses - Fix activelock XML element order: depth before owner/timeout - Filter reserved xml/xmlns prefixes from custom property namespaces - Add XmlSafetyPolicy::untrusted() for request parsing - Improve test coverage: chunked payload, If-Range validators, port matching - Update docs: clarify DavPropertyTarget as numeric primary key --- Cargo.lock | 7 -- Cargo.toml | 1 - crates/aster_forge_metrics/src/prometheus.rs | 2 +- crates/aster_forge_utils/src/http_range.rs | 12 +- crates/aster_forge_webdav/Cargo.toml | 1 - crates/aster_forge_webdav/src/backend.rs | 19 ++- crates/aster_forge_webdav/src/deltav.rs | 4 + crates/aster_forge_webdav/src/lock.rs | 10 +- crates/aster_forge_webdav/src/path.rs | 58 ++++----- crates/aster_forge_webdav/src/protocol.rs | 70 +++++++---- crates/aster_forge_webdav/src/request.rs | 20 +-- crates/aster_forge_webdav/src/resource.rs | 13 +- crates/aster_forge_webdav/src/response.rs | 77 +++++++++++- crates/aster_forge_webdav/src/xml.rs | 16 +-- crates/aster_forge_webdav/src/xml_response.rs | 9 +- crates/aster_forge_webdav/tests/actix.rs | 35 ++++++ crates/aster_forge_webdav/tests/deltav.rs | 5 + crates/aster_forge_webdav/tests/guard.rs | 10 +- crates/aster_forge_webdav/tests/property.rs | 4 + crates/aster_forge_webdav/tests/protocol.rs | 115 ++++++++++++++++-- crates/aster_forge_webdav/tests/response.rs | 81 ++++++++++-- crates/aster_forge_webdav/tests/xml.rs | 25 +++- .../aster_forge_webdav/tests/xml_response.rs | 60 +++++++++ crates/aster_forge_xml/src/syntax.rs | 3 + docs/crates/aster_forge_webdav.md | 3 +- docs/guide/new-project-integration.md | 1 + docs/index.md | 2 +- 27 files changed, 518 insertions(+), 145 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11d76d5..5c474f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -836,7 +836,6 @@ dependencies = [ "http 1.4.2", "percent-encoding", "thiserror 2.0.19", - "urlencoding", ] [[package]] @@ -5960,12 +5959,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf8-zero" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 38a0e62..7371ddf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,5 +87,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] tikv-jemalloc-ctl = "0.7" utoipa = { version = "5.5.0", features = ["chrono", "actix_extras", "repr"] } url = "2" -urlencoding = "2" uuid = { version = "1", features = ["v4"] } 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 index ef94a51..7246882 100644 --- a/crates/aster_forge_utils/src/http_range.rs +++ b/crates/aster_forge_utils/src/http_range.rs @@ -79,9 +79,11 @@ pub fn parse_single_byte_range( raw: &str, total_size: u64, ) -> Result { - let range = raw - .strip_prefix("bytes=") - .ok_or(HttpRangeError::UnsupportedUnit)?; + 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); } @@ -146,6 +148,10 @@ mod tests { 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] diff --git a/crates/aster_forge_webdav/Cargo.toml b/crates/aster_forge_webdav/Cargo.toml index 052a251..a18365f 100644 --- a/crates/aster_forge_webdav/Cargo.toml +++ b/crates/aster_forge_webdav/Cargo.toml @@ -23,4 +23,3 @@ futures.workspace = true http.workspace = true percent-encoding.workspace = true thiserror.workspace = true -urlencoding.workspace = true diff --git a/crates/aster_forge_webdav/src/backend.rs b/crates/aster_forge_webdav/src/backend.rs index 98a67c8..4acb872 100644 --- a/crates/aster_forge_webdav/src/backend.rs +++ b/crates/aster_forge_webdav/src/backend.rs @@ -91,7 +91,7 @@ pub enum DavResourceKind { Collection, } -/// Opaque product-side identity used to batch dead-property reads. +/// Product-side resource kind and numeric primary key used to batch dead-property reads. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DavPropertyTarget { pub kind: DavResourceKind, @@ -233,6 +233,10 @@ pub trait DavFileSystem: Send + Sync { 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], @@ -247,6 +251,10 @@ pub trait DavFileSystem: Send + Sync { }) } + /// Serial fallback that discards target IDs and delegates to + /// [`DavFileSystem::get_props_many`]. + /// + /// Production adapters should override this method to batch by the supplied numeric targets. fn get_props_many_for_targets<'a>( &'a self, targets: &'a [(DavPath, DavPropertyTarget)], @@ -293,7 +301,8 @@ pub enum DavLockPreflightError { #[derive(Debug, Clone)] pub enum DavLockError { - Conflict(DavLock), + Conflict(Box), + TokenMismatch, LimitExceeded, Backend, } @@ -314,13 +323,13 @@ pub trait DavLockSystem: Send + Sync { deep: bool, ) -> LsFuture<'_, Result>; - fn unlock(&self, path: &DavPath, token: &str) -> LsFuture<'_, Result<(), ()>>; + fn unlock(&self, path: &DavPath, token: &str) -> LsFuture<'_, Result<(), DavLockError>>; fn refresh( &self, path: &DavPath, token: &str, timeout: Option, - ) -> LsFuture<'_, Result>; + ) -> LsFuture<'_, Result>; fn check( &self, path: &DavPath, @@ -343,5 +352,5 @@ pub trait DavLockSystem: Send + Sync { }) } fn conflicting_locks(&self, path: &DavPath, deep: bool) -> LsFuture<'_, Vec>; - fn delete(&self, path: &DavPath) -> LsFuture<'_, Result<(), ()>>; + 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 index 4060f50..75ff89e 100644 --- a/crates/aster_forge_webdav/src/deltav.rs +++ b/crates/aster_forge_webdav/src/deltav.rs @@ -38,6 +38,10 @@ pub fn version_tree_report_error_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")), diff --git a/crates/aster_forge_webdav/src/lock.rs b/crates/aster_forge_webdav/src/lock.rs index 008a2e7..8358578 100644 --- a/crates/aster_forge_webdav/src/lock.rs +++ b/crates/aster_forge_webdav/src/lock.rs @@ -6,7 +6,7 @@ use http::header::{CACHE_CONTROL, CONTENT_TYPE}; use http::{HeaderMap, HeaderValue, StatusCode}; use crate::DavLockSystem; -use crate::response::xml_request_error_response; +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, @@ -321,11 +321,3 @@ fn lock_condition_response( .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); Ok(response) } - -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 -} diff --git a/crates/aster_forge_webdav/src/path.rs b/crates/aster_forge_webdav/src/path.rs index 5b8bc24..7359da2 100644 --- a/crates/aster_forge_webdav/src/path.rs +++ b/crates/aster_forge_webdav/src/path.rs @@ -1,6 +1,8 @@ //! Canonical WebDAV path handling. -use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; +use std::str; + +use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode}; const DAV_HREF_PATH_SET: &AsciiSet = &CONTROLS .add(b' ') @@ -20,15 +22,12 @@ const DAV_HREF_PATH_SET: &AsciiSet = &CONTROLS /// A normalized path relative to a WebDAV mount. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct DavPath { - raw: String, - decoded: Vec, + canonical: String, } /// Parses a mount-relative request path and returns its canonical decoded representation. -pub fn decode_relative_path(relative: &str) -> Result<(DavPath, String), DavPathError> { - let path = DavPath::new(relative)?; - let decoded = path.as_str().to_string(); - Ok((path, decoded)) +pub fn decode_relative_path(relative: &str) -> Result { + DavPath::new(relative) } /// Percent-encodes a DAV href while preserving path separators. @@ -55,9 +54,15 @@ pub fn href_for_dav_path(prefix: &str, path: &DavPath) -> String { } /// Returns a child path with collection trailing-slash semantics. -#[must_use] -pub fn child_relative_path(parent: &str, name: &[u8], is_collection: bool) -> String { - let name = String::from_utf8_lossy(name); +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('/') { @@ -68,7 +73,7 @@ pub fn child_relative_path(parent: &str, name: &[u8], is_collection: bool) -> St if is_collection && !relative.ends_with('/') { relative.push('/'); } - relative + Ok(relative) } /// Returns the canonical parent collection path. @@ -112,51 +117,46 @@ pub enum DavPathError { /// 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 raw = ensure_leading_slash(path); - let decoded = urlencoding::decode(&raw) - .map_err(|_| DavPathError::InvalidEncoding)? - .into_owned(); - let raw = clean_decoded_path(&decoded)?; - let decoded = raw.as_bytes().to_vec(); - Ok(Self { raw, decoded }) + let encoded = ensure_leading_slash(path); + 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 { - raw: "/".to_string(), - decoded: b"/".to_vec(), + canonical: "/".to_string(), } } /// Returns the decoded canonical path bytes. #[must_use] pub fn as_bytes(&self) -> &[u8] { - &self.decoded + self.canonical.as_bytes() } /// Returns the decoded canonical UTF-8 path. #[must_use] pub fn as_str(&self) -> &str { - &self.raw + &self.canonical } /// Returns whether the path denotes a collection alias. #[must_use] pub fn is_collection(&self) -> bool { - self.raw == "/" || self.raw.ends_with('/') - } - - /// Returns the decoded canonical path as a relative mount path. - #[must_use] - pub fn relative(&self) -> &str { - self.as_str() + self.canonical == "/" || self.canonical.ends_with('/') } } diff --git a/crates/aster_forge_webdav/src/protocol.rs b/crates/aster_forge_webdav/src/protocol.rs index dfa45f7..1f30e5a 100644 --- a/crates/aster_forge_webdav/src/protocol.rs +++ b/crates/aster_forge_webdav/src/protocol.rs @@ -3,7 +3,9 @@ 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, @@ -231,9 +233,7 @@ pub fn destination_relative_path( .map_err(|_| DavProtocolError::bad_request("Invalid Destination header"))?; match (uri.scheme_str(), uri.authority()) { (Some(scheme), Some(authority)) => { - if !scheme.eq_ignore_ascii_case(request_scheme) - || !authority.as_str().eq_ignore_ascii_case(request_host) - { + if !origin_authority_matches(scheme, authority, request_scheme, request_host) { return Err(DavProtocolError::bad_request( "Destination must stay on this WebDAV server", )); @@ -270,9 +270,8 @@ pub fn parse_if_header(headers: &HeaderMap) -> Result, DavProto /// Resolves referenced resources and enforces the complete WebDAV `If` state-list precondition. /// -/// Conditions inside one list are AND-connected. Lists for one resource are OR-connected. Every -/// tagged resource group must have a matching list; an untagged header contains one request-target -/// group and follows the same list semantics. +/// 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, @@ -296,15 +295,15 @@ pub async fn enforce_if_header( Some(path) => resolver.resolve_if_state(path).await?, None => DavIfResourceState::default(), }; - if !group + if group .lists .iter() .any(|list| evaluate_if_state_list(list, &state)) { - return Err(DavProtocolError::precondition_failed().into()); + return Ok(()); } } - Ok(()) + Err(DavProtocolError::precondition_failed().into()) } /// Resolves `If` state from the canonical filesystem and lock backend ports. @@ -388,9 +387,7 @@ fn tagged_dav_path( .map_err(|_| DavProtocolError::bad_request("Invalid If header"))?; let path = match (uri.scheme_str(), uri.authority()) { (Some(scheme), Some(authority)) => { - if !scheme.eq_ignore_ascii_case(request_scheme) - || !authority.as_str().eq_ignore_ascii_case(request_host) - { + if !origin_authority_matches(scheme, authority, request_scheme, request_host) { return Ok(None); } uri.path() @@ -475,11 +472,7 @@ pub fn parse_lock_timeout( .strip_prefix("Second-") .and_then(|seconds| seconds.parse::().ok()) { - let timeout = Duration::from_secs(seconds); - if timeout > maximum { - return Err(DavProtocolError::bad_request("Invalid Timeout header")); - } - return Ok(timeout); + return Ok(Duration::from_secs(seconds).min(maximum)); } } Err(DavProtocolError::bad_request("Invalid Timeout header")) @@ -605,7 +598,41 @@ fn parse_http_date_header( .map_err(|_| DavProtocolError::bad_request(invalid_message)) } -fn strip_mount_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> { +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 @@ -807,8 +834,7 @@ fn if_tag_matches_path( }; match (uri.scheme_str(), uri.authority()) { (Some(scheme), Some(authority)) => { - scheme.eq_ignore_ascii_case(request_scheme) - && authority.as_str().eq_ignore_ascii_case(request_host) + origin_authority_matches(scheme, authority, request_scheme, request_host) && path_equivalent(uri.path(), request_path) } (None, None) => path_equivalent(uri.path(), request_path), @@ -820,8 +846,8 @@ fn path_equivalent(left: &str, right: &str) -> bool { if left == right { return true; } - let left_decoded = urlencoding::decode(left).ok(); - let right_decoded = urlencoding::decode(right).ok(); + 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, diff --git a/crates/aster_forge_webdav/src/request.rs b/crates/aster_forge_webdav/src/request.rs index 85162a7..35c9d27 100644 --- a/crates/aster_forge_webdav/src/request.rs +++ b/crates/aster_forge_webdav/src/request.rs @@ -7,7 +7,7 @@ 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, + parse_propfind_depth, strip_mount_prefix, }; /// WebDAV method recognized by the protocol layer. @@ -136,21 +136,9 @@ impl DavRequestHead { mount_path: &str, origin: &DavRequestOrigin, ) -> Result { - let relative = uri - .path() - .strip_prefix(mount_path) - .filter(|_| { - mount_path == "/" - || uri.path() == mount_path - || uri - .path() - .as_bytes() - .get(mount_path.len()) - .is_some_and(|byte| *byte == b'/') - }) - .ok_or_else(|| { - DavProtocolError::bad_request("Request target must stay under WebDAV prefix") - })?; + 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"))?; diff --git a/crates/aster_forge_webdav/src/resource.rs b/crates/aster_forge_webdav/src/resource.rs index 3324e0b..b5f0d7a 100644 --- a/crates/aster_forge_webdav/src/resource.rs +++ b/crates/aster_forge_webdav/src/resource.rs @@ -3,6 +3,7 @@ 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, @@ -178,11 +179,7 @@ pub fn mutation_plan_error_response(error: DavMutationPlanError) -> DavResponse DavMutationPlanError::Forbidden => StatusCode::FORBIDDEN, DavMutationPlanError::PreconditionFailed => StatusCode::PRECONDITION_FAILED, }; - let mut response = DavResponse::empty(status); - response - .headers - .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); - response + no_store_empty_response(status) } /// Builds the successful MKCOL response. @@ -251,11 +248,7 @@ pub fn mutation_success_response(destination_existed: bool) -> DavResponse { } else { StatusCode::CREATED }; - let mut response = DavResponse::empty(status); - response - .headers - .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); - response + no_store_empty_response(status) } /// Builds a 207 response for typed recursive mutation failures. diff --git a/crates/aster_forge_webdav/src/response.rs b/crates/aster_forge_webdav/src/response.rs index 1b4a0cb..4d4caaf 100644 --- a/crates/aster_forge_webdav/src/response.rs +++ b/crates/aster_forge_webdav/src/response.rs @@ -2,12 +2,14 @@ use std::time::SystemTime; -use aster_forge_utils::http_range::{HttpByteRange, parse_single_byte_range}; -use aster_forge_utils::http_validators::format_http_date; +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, LAST_MODIFIED, RANGE, + CONTENT_TYPE, ETAG, IF_RANGE, LAST_MODIFIED, RANGE, }; use http::{HeaderMap, HeaderValue, StatusCode}; @@ -132,6 +134,10 @@ pub(crate) fn xml_request_error_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", @@ -169,7 +175,7 @@ pub fn backend_error_response(error: &DavBackendError) -> DavResponse { no_store_empty_response(status) } -fn no_store_empty_response(status: StatusCode) -> DavResponse { +pub(crate) fn no_store_empty_response(status: StatusCode) -> DavResponse { let mut response = DavResponse::empty(status); response .headers @@ -254,7 +260,7 @@ pub fn plan_download_response( } } - let range = if head_only { + 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 { @@ -262,7 +268,15 @@ pub fn plan_download_response( }; match parse_single_byte_range(raw, content_length) { Ok(range) => Some(range), - Err(_) => return Ok(range_not_satisfiable_plan(content_length)), + 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 @@ -300,6 +314,57 @@ pub fn plan_download_response( 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 { diff --git a/crates/aster_forge_webdav/src/xml.rs b/crates/aster_forge_webdav/src/xml.rs index f3cd5b9..212c76e 100644 --- a/crates/aster_forge_webdav/src/xml.rs +++ b/crates/aster_forge_webdav/src/xml.rs @@ -8,7 +8,7 @@ use std::io::Read; use aster_forge_xml::{ BorrowedDocument, ElementRef, Error as ForgeXmlError, NodeRef, OwnedDocument, ParseOptions, - XmlSafetyError, XmlStreamWriter, XmlWriteAttribute, + XmlSafetyError, XmlSafetyPolicy, XmlStreamWriter, XmlWriteAttribute, }; const DAV_NAMESPACE: &str = "DAV:"; @@ -22,6 +22,9 @@ pub enum DavXmlError { /// 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, @@ -35,12 +38,11 @@ impl From for DavXmlError { match error { XmlSafetyError::ExternalEntity => Self::ExternalEntity, XmlSafetyError::TooDeep => Self::TooDeep, + XmlSafetyError::InputTooLarge | XmlSafetyError::TextTooLarge => Self::TooLarge, XmlSafetyError::InvalidPolicy - | XmlSafetyError::InputTooLarge | XmlSafetyError::OutputTooLarge | XmlSafetyError::TooManyElements | XmlSafetyError::TooManyAttributes - | XmlSafetyError::TextTooLarge | XmlSafetyError::TooManyEvents | XmlSafetyError::InvalidEncoding | XmlSafetyError::Malformed => Self::Malformed, @@ -400,15 +402,15 @@ fn requested_property>(element: ElementRef<'_, S>) -> DavRequeste fn xml_lang_value<'document, S: AsRef<[u8]>>( element: ElementRef<'document, S>, ) -> Option<&'document str> { - element - .attribute("xml:lang") - .or_else(|| element.attribute("lang")) + 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().trim_whitespace(true) + ParseOptions::new() + .safety_policy(XmlSafetyPolicy::untrusted()) + .trim_whitespace(true) } fn map_forge_xml_error(error: ForgeXmlError) -> DavXmlError { diff --git a/crates/aster_forge_webdav/src/xml_response.rs b/crates/aster_forge_webdav/src/xml_response.rs index 5e8ce07..28fe71e 100644 --- a/crates/aster_forge_webdav/src/xml_response.rs +++ b/crates/aster_forge_webdav/src/xml_response.rs @@ -346,6 +346,10 @@ fn active_lock_element(lock: &DavLockXml) -> DavXmlElement { .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())); } @@ -363,10 +367,6 @@ fn active_lock_element(lock: &DavLockXml) -> DavXmlElement { encode_href(&lock.token), ))); active.children.push(DavXmlNode::Element(token)); - active.children.push(DavXmlNode::Element(dav_text_element( - "depth", - if lock.deep { "Infinity" } else { "0" }, - ))); let mut lockroot = dav_element("lockroot"); lockroot.children.push(DavXmlNode::Element(dav_text_element( @@ -404,6 +404,7 @@ fn property_element(name: &DavRequestedProperty, child: Option) -> D 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) diff --git a/crates/aster_forge_webdav/tests/actix.rs b/crates/aster_forge_webdav/tests/actix.rs index 1941f44..2d67398 100644 --- a/crates/aster_forge_webdav/tests/actix.rs +++ b/crates/aster_forge_webdav/tests/actix.rs @@ -5,6 +5,7 @@ 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() @@ -15,6 +16,23 @@ async fn payload_from_bytes(bytes: Bytes) -> web::Payload { .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; @@ -44,6 +62,23 @@ async fn bounded_xml_body_accepts_the_exact_limit_and_rejects_one_byte_over() { 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] diff --git a/crates/aster_forge_webdav/tests/deltav.rs b/crates/aster_forge_webdav/tests/deltav.rs index 023eb87..dcf1244 100644 --- a/crates/aster_forge_webdav/tests/deltav.rs +++ b/crates/aster_forge_webdav/tests/deltav.rs @@ -74,6 +74,11 @@ fn report_errors_select_plain_or_dav_xml_responses() { "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] diff --git a/crates/aster_forge_webdav/tests/guard.rs b/crates/aster_forge_webdav/tests/guard.rs index 59a8d98..6ff11a9 100644 --- a/crates/aster_forge_webdav/tests/guard.rs +++ b/crates/aster_forge_webdav/tests/guard.rs @@ -177,8 +177,8 @@ impl DavLockSystem for TestLockSystem { Box::pin(async { Err(DavLockError::Backend) }) } - fn unlock(&self, _path: &DavPath, _token: &str) -> LsFuture<'_, Result<(), ()>> { - Box::pin(async { Err(()) }) + fn unlock(&self, _path: &DavPath, _token: &str) -> LsFuture<'_, Result<(), DavLockError>> { + Box::pin(async { Err(DavLockError::TokenMismatch) }) } fn refresh( @@ -186,8 +186,8 @@ impl DavLockSystem for TestLockSystem { _path: &DavPath, _token: &str, _timeout: Option, - ) -> LsFuture<'_, Result> { - Box::pin(async { Err(()) }) + ) -> LsFuture<'_, Result> { + Box::pin(async { Err(DavLockError::TokenMismatch) }) } fn check( @@ -220,7 +220,7 @@ impl DavLockSystem for TestLockSystem { Box::pin(async move { conflicts }) } - fn delete(&self, _path: &DavPath) -> LsFuture<'_, Result<(), ()>> { + fn delete(&self, _path: &DavPath) -> LsFuture<'_, Result<(), DavLockError>> { Box::pin(async { Ok(()) }) } } diff --git a/crates/aster_forge_webdav/tests/property.rs b/crates/aster_forge_webdav/tests/property.rs index df8f557..66f3510 100644 --- a/crates/aster_forge_webdav/tests/property.rs +++ b/crates/aster_forge_webdav/tests/property.rs @@ -177,4 +177,8 @@ fn property_response_helpers_own_multistatus_depth_and_xml_error_contracts() { 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 index c9bd403..86b7226 100644 --- a/crates/aster_forge_webdav/tests/protocol.rs +++ b/crates/aster_forge_webdav/tests/protocol.rs @@ -70,6 +70,10 @@ fn dav_path_canonicalizes_dot_segments_and_rejects_escape() { DavPath::new("/%2e%2e/secret.txt"), Err(DavPathError::PathEscape) )); + assert!(matches!( + DavPath::new("/bad%FFname"), + Err(DavPathError::InvalidEncoding) + )); } #[test] @@ -107,9 +111,21 @@ fn href_and_relative_path_helpers_preserve_collection_semantics() { "/webdav/folder/file%20name%252B.txt" ); assert_eq!( - child_relative_path("/folder/", b"child", true), + 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()) @@ -229,6 +245,46 @@ fn destination_is_same_origin_and_mount_scoped() { .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", @@ -368,7 +424,7 @@ fn if_evaluator_uses_or_between_lists_and_and_inside_each_list() { } #[test] -fn if_evaluator_requires_every_tagged_resource_group_to_match() { +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([ @@ -402,13 +458,25 @@ fn if_evaluator_requires_every_tagged_resource_group_to_match() { let partially_matching = parsed_if(r#" () ()"#); - let error = futures::executor::block_on(enforce_if_header( + 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, @@ -587,12 +655,14 @@ fn lock_timeout_uses_bounded_server_policy() { Duration::from_secs(60) ); - for value in ["Second-604801", "Second-18446744073709551615", "Extension"] { - assert!( - parse_lock_timeout(&headers("Timeout", value), maximum).is_err(), - "{value} should be rejected" + 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( @@ -648,6 +718,37 @@ fn etag_and_date_preconditions_keep_http_precedence() { .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] diff --git a/crates/aster_forge_webdav/tests/response.rs b/crates/aster_forge_webdav/tests/response.rs index b2e8f9c..abbae65 100644 --- a/crates/aster_forge_webdav/tests/response.rs +++ b/crates/aster_forge_webdav/tests/response.rs @@ -10,7 +10,7 @@ use aster_forge_webdav::{ 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, LAST_MODIFIED, RANGE, + CONTENT_TYPE, ETAG, IF_MATCH, IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, RANGE, }; use http::{HeaderMap, HeaderValue, Uri}; @@ -88,7 +88,7 @@ fn full_get_plan_contains_complete_response_and_storage_contract() { #[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")); + headers.insert(RANGE, HeaderValue::from_static(" BYTES=5-99")); let plan = plan_download_response( &headers, false, @@ -133,13 +133,7 @@ fn head_ignores_even_an_unsatisfiable_range_and_never_selects_a_body() { #[test] fn malformed_unsatisfiable_and_empty_ranges_return_complete_416_shells() { - for (raw, content_length) in [ - ("items=0-1", 20), - ("bytes=0-1,3-4", 20), - ("bytes=-0", 20), - ("bytes=20-", 20), - ("bytes=0-0", 0), - ] { + 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( @@ -171,6 +165,75 @@ fn malformed_unsatisfiable_and_empty_ranges_return_complete_416_shells() { } } +#[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(); diff --git a/crates/aster_forge_webdav/tests/xml.rs b/crates/aster_forge_webdav/tests/xml.rs index b9643b6..66fd44d 100644 --- a/crates/aster_forge_webdav/tests/xml.rs +++ b/crates/aster_forge_webdav/tests/xml.rs @@ -182,6 +182,25 @@ fn proppatch_preserves_order_qnames_values_and_inherited_language() { ); } +#[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( @@ -318,6 +337,10 @@ fn xml_reader_maps_io_invalid_encoding_and_size_boundaries() { 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); @@ -331,7 +354,7 @@ fn xml_reader_maps_io_invalid_encoding_and_size_boundaries() { assert_eq!(exact.len(), max_input_bytes + 1); assert_eq!( DavXmlElement::parse_reader(Cursor::new(&exact)), - Err(DavXmlError::Malformed) + Err(DavXmlError::TooLarge) ); } diff --git a/crates/aster_forge_webdav/tests/xml_response.rs b/crates/aster_forge_webdav/tests/xml_response.rs index 10f7cff..6aecb3b 100644 --- a/crates/aster_forge_webdav/tests/xml_response.rs +++ b/crates/aster_forge_webdav/tests/xml_response.rs @@ -126,6 +126,38 @@ fn lockdiscovery_covers_owner_timeout_scope_depth_token_and_root() { assert!(output.contains("Infinity"), "{output}"); assert!(output.contains("urn:uuid:a%20b"), "{output}"); assert!(output.contains("用户 & owner"), "{output}"); + let parsed = DavXmlElement::parse(output.as_bytes()).expect("lockdiscovery XML"); + let active_locks = parsed.child_elements().collect::>(); + 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}"); @@ -192,6 +224,34 @@ fn property_builders_preserve_qnames_values_and_namespace_declarations() { ); assert!(custom_xml.contains(" Error { } } +// External-entity classification relies on quick-xml reporting `InvalidBangMarkup` with +// `error_position()` at the opening ` Date: Sat, 25 Jul 2026 12:22:47 +0800 Subject: [PATCH 11/11] refactor(aster_forge_webdav): remove DavPropertyTarget and add path separator validation Remove the batching abstraction DavPropertyTarget and related methods from the backend trait: - Remove DavPropertyTarget struct (kind + numeric ID pair) - Remove DavMetaData::property_target() method - Remove DavFileSystem::get_props_many_for_targets() method - Update public exports to remove DavPropertyTarget Add path security validation: - Reject percent-encoded path separators (%2F, %2f, %5C, %5c) before decoding - Prevent path traversal via encoded separators - Add contains_encoded_path_separator() helper - Add test coverage for encoded separator rejection and double-encoding behavior Update documentation to reflect that dead-property batching now uses DavPath only, with product adapters responsible for parsing database identity. --- crates/aster_forge_webdav/src/backend.rs | 28 --------------------- crates/aster_forge_webdav/src/lib.rs | 4 +-- crates/aster_forge_webdav/src/path.rs | 11 ++++++++ crates/aster_forge_webdav/tests/backend.rs | 13 ++-------- crates/aster_forge_webdav/tests/protocol.rs | 19 ++++++++++++++ docs/crates/aster_forge_webdav.md | 2 +- 6 files changed, 35 insertions(+), 42 deletions(-) diff --git a/crates/aster_forge_webdav/src/backend.rs b/crates/aster_forge_webdav/src/backend.rs index 4acb872..e478d65 100644 --- a/crates/aster_forge_webdav/src/backend.rs +++ b/crates/aster_forge_webdav/src/backend.rs @@ -91,13 +91,6 @@ pub enum DavResourceKind { Collection, } -/// Product-side resource kind and numeric primary key used to batch dead-property reads. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct DavPropertyTarget { - pub kind: DavResourceKind, - pub id: i64, -} - /// Protocol-visible state used to evaluate one resource referenced by an `If` header. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DavIfResourceState { @@ -159,9 +152,6 @@ pub trait DavMetaData: Send + Sync { None } fn created(&self) -> FsResult; - fn property_target(&self) -> Option { - None - } fn is_empty(&self) -> bool { self.len() == 0 } @@ -251,24 +241,6 @@ pub trait DavFileSystem: Send + Sync { }) } - /// Serial fallback that discards target IDs and delegates to - /// [`DavFileSystem::get_props_many`]. - /// - /// Production adapters should override this method to batch by the supplied numeric targets. - fn get_props_many_for_targets<'a>( - &'a self, - targets: &'a [(DavPath, DavPropertyTarget)], - do_content: bool, - ) -> FsFuture<'a, HashMap>> { - Box::pin(async move { - let paths = targets - .iter() - .map(|(path, _)| path.clone()) - .collect::>(); - self.get_props_many(&paths, do_content).await - }) - } - fn patch_props<'a>( &'a self, _path: &'a DavPath, diff --git a/crates/aster_forge_webdav/src/lib.rs b/crates/aster_forge_webdav/src/lib.rs index 7ded478..036fa98 100644 --- a/crates/aster_forge_webdav/src/lib.rs +++ b/crates/aster_forge_webdav/src/lib.rs @@ -35,8 +35,8 @@ pub mod xml_response; pub use backend::{ DavBackendError, DavBackendErrorKind, DavContentStream, DavDirEntry, DavFile, DavFileSystem, DavIfResourceState, DavIfStateResolver, DavLock, DavLockError, DavLockPreflightError, - DavLockSystem, DavMetaData, DavProp, DavPropertyTarget, DavResourceKind, FsError, FsFuture, - FsResult, FsStream, LsFuture, OpenOptions, ReadDirMeta, + DavLockSystem, DavMetaData, DavProp, DavResourceKind, FsError, FsFuture, FsResult, FsStream, + LsFuture, OpenOptions, ReadDirMeta, }; pub use deltav::{ DavVersionTreeReportError, validate_version_tree_report, version_control_response, diff --git a/crates/aster_forge_webdav/src/path.rs b/crates/aster_forge_webdav/src/path.rs index 7359da2..4a4d97f 100644 --- a/crates/aster_forge_webdav/src/path.rs +++ b/crates/aster_forge_webdav/src/path.rs @@ -126,6 +126,9 @@ 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)?; @@ -160,6 +163,14 @@ impl DavPath { } } +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(); diff --git a/crates/aster_forge_webdav/tests/backend.rs b/crates/aster_forge_webdav/tests/backend.rs index c00a334..f00b163 100644 --- a/crates/aster_forge_webdav/tests/backend.rs +++ b/crates/aster_forge_webdav/tests/backend.rs @@ -1,6 +1,4 @@ -use aster_forge_webdav::{ - DavBackendError, DavBackendErrorKind, DavPropertyTarget, DavResourceKind, FsError, OpenOptions, -}; +use aster_forge_webdav::{DavBackendError, DavBackendErrorKind, FsError, OpenOptions}; #[test] fn filesystem_errors_map_exhaustively_to_protocol_backend_categories() { @@ -22,7 +20,7 @@ fn filesystem_errors_map_exhaustively_to_protocol_backend_categories() { } #[test] -fn open_modes_and_property_targets_are_transport_neutral_values() { +fn open_modes_are_transport_neutral_values() { assert_eq!( OpenOptions::read(), OpenOptions { @@ -37,11 +35,4 @@ fn open_modes_and_property_targets_are_transport_neutral_values() { ..OpenOptions::default() } ); - - let target = DavPropertyTarget { - kind: DavResourceKind::Collection, - id: i64::MAX, - }; - assert_eq!(target.kind, DavResourceKind::Collection); - assert_eq!(target.id, i64::MAX); } diff --git a/crates/aster_forge_webdav/tests/protocol.rs b/crates/aster_forge_webdav/tests/protocol.rs index 86b7226..e7deec3 100644 --- a/crates/aster_forge_webdav/tests/protocol.rs +++ b/crates/aster_forge_webdav/tests/protocol.rs @@ -76,6 +76,25 @@ fn dav_path_canonicalizes_dot_segments_and_rejects_escape() { )); } +#[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 [ diff --git a/docs/crates/aster_forge_webdav.md b/docs/crates/aster_forge_webdav.md index 6e16c25..e7014b9 100644 --- a/docs/crates/aster_forge_webdav.md +++ b/docs/crates/aster_forge_webdav.md @@ -39,7 +39,7 @@ Forge 负责: - 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。 -- `DavPropertyTarget` 只携带资源种类与产品侧数值主键,用于批量 dead-property 读取;Forge 不解释数据库身份。 +- 批量 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。