aster_forge_webdav/
actix.rs

1//! Optional Actix transport adapter for the `WebDAV` protocol model.
2
3use actix_web::http::{StatusCode as ActixStatusCode, header as actix_header};
4use actix_web::{HttpRequest, HttpResponse};
5use bytes::Bytes;
6use futures::{Stream, StreamExt};
7use http::{HeaderMap, HeaderName, HeaderValue, Uri};
8
9use crate::protocol::DavProtocolError;
10use crate::{
11    DavBodyError, DavBodyPolicy, DavCapabilityContext, DavCapabilityProvider,
12    DavCapabilitySnapshot, DavCapabilityTarget, DavConditionalPlan, DavConditionalResource,
13    DavFileSystem, DavIfEvaluationError, DavLockEnforcementError, DavLockSystem, DavMethod,
14    DavMutationCredentials, DavParentCollectionError, DavPath, DavRequestHead, DavRequestOrigin,
15    DavRequestTarget, DavResponse, DavResponseBody, IfHeader,
16};
17
18/// Request body prepared according to the selected `WebDAV` method contract.
19pub enum DavPreparedBody {
20    None,
21    Xml(Vec<u8>),
22    Bytes(Vec<u8>),
23}
24
25impl DavPreparedBody {
26    /// Returns the collected XML bytes, or an empty slice for bodyless methods.
27    #[must_use]
28    pub fn xml(&self) -> &[u8] {
29        match self {
30            Self::None | Self::Bytes(_) => &[],
31            Self::Xml(body) => body,
32        }
33    }
34
35    /// Returns collected opaque bytes, or an empty slice for other body policies.
36    #[must_use]
37    pub fn bytes(&self) -> &[u8] {
38        match self {
39            Self::Bytes(body) => body,
40            Self::None | Self::Xml(_) => &[],
41        }
42    }
43}
44
45/// Parses an Actix request into the transport-neutral request head.
46///
47/// # Errors
48///
49/// Returns [`DavProtocolError`] when the URI, mount path, origin, or headers are invalid.
50pub fn request_head(
51    request: &HttpRequest,
52    mount_path: &str,
53) -> Result<Option<DavRequestHead>, DavProtocolError> {
54    let request_target = request_target(request, mount_path)?;
55    let Some(method) = DavMethod::from_name(request.method().as_str()) else {
56        return Ok(None);
57    };
58    let headers = convert_header_map(request.headers())?;
59    DavRequestHead::parse_known_method(method, &request_target, &headers).map(Some)
60}
61
62/// Parses the request target before resolving whether the HTTP method is known to Forge.
63///
64/// # Errors
65///
66/// Returns [`DavProtocolError`] when the URI, origin, or mount-relative target is invalid.
67pub fn request_target<'a>(
68    request: &HttpRequest,
69    mount_path: &'a str,
70) -> Result<DavRequestTarget<'a>, DavProtocolError> {
71    let uri: Uri = request
72        .uri()
73        .to_string()
74        .parse()
75        .map_err(|_| DavProtocolError::bad_request("Invalid request URI"))?;
76    let connection = request.connection_info();
77    let origin = DavRequestOrigin {
78        scheme: connection.scheme().to_string(),
79        host: connection.host().to_string(),
80    };
81    DavRequestHead::parse_target(&uri, mount_path, &origin)
82}
83
84/// Resolves and validates the product capability declaration.
85///
86/// # Errors
87///
88/// Returns a typed failure when provider lookup or capability validation fails.
89pub async fn capability_snapshot<Provider: DavCapabilityProvider>(
90    provider: &Provider,
91    target: &DavCapabilityTarget,
92    context: &DavCapabilityContext,
93) -> Result<DavCapabilitySnapshot, crate::DavCapabilityEvaluationError> {
94    crate::plan_capabilities_with_provider(provider, target, context).await
95}
96
97/// Applies the resource-aware dispatch gate to an Actix request method.
98///
99/// # Errors
100///
101/// Returns [`crate::DavMethodGateError`] when the snapshot does not dispatch the request method.
102pub fn gate_request_method(
103    request: &HttpRequest,
104    snapshot: &DavCapabilitySnapshot,
105) -> Result<DavMethod, crate::DavMethodGateError> {
106    crate::gate_method(DavMethod::from_name(request.method().as_str()), snapshot)
107}
108
109/// Converts a transport-neutral response into an Actix response.
110pub fn into_response(response: DavResponse) -> HttpResponse {
111    let status = ActixStatusCode::from_u16(response.status.as_u16())
112        .unwrap_or(ActixStatusCode::INTERNAL_SERVER_ERROR);
113    let mut builder = HttpResponse::build(status);
114    for (name, value) in &response.headers {
115        let name = actix_header::HeaderName::from_bytes(name.as_str().as_bytes());
116        let value = actix_header::HeaderValue::from_bytes(value.as_bytes());
117        if let (Ok(name), Ok(value)) = (name, value) {
118            builder.insert_header((name, value));
119        }
120    }
121    match response.body {
122        DavResponseBody::Empty => builder.finish(),
123        DavResponseBody::Bytes(body) => builder.body(body),
124        DavResponseBody::Stream(stream) => streaming_response(&mut builder, stream),
125        DavResponseBody::MultiStatus(stream) => streaming_response(&mut builder, stream),
126    }
127}
128
129fn streaming_response<S, E>(builder: &mut actix_web::HttpResponseBuilder, stream: S) -> HttpResponse
130where
131    S: Stream<Item = Result<Bytes, E>> + 'static,
132    E: 'static,
133{
134    let stream = stream.map(|item| {
135        item.map_err(|_| {
136            actix_web::error::ErrorInternalServerError("WebDAV response stream failed")
137        })
138    });
139    builder.streaming(stream)
140}
141
142/// Maps a transport-neutral protocol error into its Actix response.
143#[must_use]
144#[expect(
145    clippy::needless_pass_by_value,
146    reason = "The owned error signature composes directly with Result::map_err at the Actix boundary."
147)]
148pub fn protocol_error_response(error: DavProtocolError) -> HttpResponse {
149    into_response(crate::protocol_error_response(&error))
150}
151
152/// Copies Actix headers into the transport-neutral map.
153///
154/// # Errors
155///
156/// Returns [`DavProtocolError`] when an Actix header cannot be represented by `http` 1.x.
157pub fn converted_headers(source: &actix_header::HeaderMap) -> Result<HeaderMap, DavProtocolError> {
158    convert_header_map(source)
159}
160
161/// Resolves and enforces a parsed `WebDAV` `If` header through the canonical backend ports.
162///
163/// # Errors
164///
165/// Returns [`DavIfEvaluationError`] when DAV `If` evaluation or backend access fails.
166pub async fn enforce_if_header_with_backends(
167    if_header: Option<&IfHeader>,
168    filesystem: &dyn DavFileSystem,
169    lock_system: &dyn DavLockSystem,
170    request_path: &DavPath,
171    prefix: &str,
172    request_scheme: &str,
173    request_host: &str,
174) -> Result<(), DavIfEvaluationError> {
175    crate::enforce_if_header_with_backends(
176        if_header,
177        filesystem,
178        lock_system,
179        request_path,
180        prefix,
181        request_scheme,
182        request_host,
183    )
184    .await
185}
186
187/// Enforces resource lock submission and returns a compact typed failure.
188///
189/// # Errors
190///
191/// Returns [`DavLockEnforcementError`] when a conflicting lock exists or lock lookup fails.
192pub async fn enforce_unlocked(
193    lock_system: &dyn DavLockSystem,
194    path: &DavPath,
195    deep: bool,
196    prefix: &str,
197    if_header: Option<&IfHeader>,
198    request_scheme: &str,
199    request_host: &str,
200) -> Result<DavMutationCredentials, DavLockEnforcementError> {
201    crate::enforce_unlocked(
202        lock_system,
203        path,
204        deep,
205        prefix,
206        if_header,
207        request_scheme,
208        request_host,
209    )
210    .await
211}
212
213/// Enforces lock submission for the canonical parent and returns a compact typed failure.
214///
215/// # Errors
216///
217/// Returns [`DavLockEnforcementError`] when the parent is locked or lock lookup fails.
218pub async fn enforce_parent_unlocked(
219    lock_system: &dyn DavLockSystem,
220    path: &DavPath,
221    prefix: &str,
222    if_header: Option<&IfHeader>,
223    request_scheme: &str,
224    request_host: &str,
225) -> Result<DavMutationCredentials, DavLockEnforcementError> {
226    crate::enforce_parent_unlocked(
227        lock_system,
228        path,
229        prefix,
230        if_header,
231        request_scheme,
232        request_host,
233    )
234    .await
235}
236
237/// Converts Actix headers and runs the method-aware conditional request planner.
238///
239/// # Errors
240///
241/// Returns a compact typed failure when header conversion or conditional planning fails.
242pub fn plan_http_conditionals(
243    headers: &actix_header::HeaderMap,
244    method: DavMethod,
245    resource: DavConditionalResource<'_>,
246) -> Result<DavConditionalPlan, crate::DavConditionalPlanError> {
247    let headers = converted_headers(headers)?;
248    crate::plan_http_conditionals(method, &headers, resource)
249}
250
251/// Maps capability evaluation failures to Actix at the handler boundary.
252#[must_use]
253pub fn capability_error_response(error: &crate::DavCapabilityEvaluationError) -> HttpResponse {
254    into_response(crate::capability_evaluation_error_response(error))
255}
256
257/// Maps a rejected method gate to the canonical 405 Actix response.
258#[must_use]
259pub fn method_gate_error_response(snapshot: &DavCapabilitySnapshot) -> HttpResponse {
260    into_response(crate::method_not_allowed_response(snapshot))
261}
262
263/// Maps conditional planning failures to Actix at the handler boundary.
264#[must_use]
265pub fn conditional_plan_error_response(error: &crate::DavConditionalPlanError) -> HttpResponse {
266    into_response(crate::conditional_plan_error_response(error))
267}
268
269/// Maps lock-enforcement failures to Actix at the handler boundary.
270#[must_use]
271pub fn lock_enforcement_error_response(
272    error: DavLockEnforcementError,
273    prefix: &str,
274) -> HttpResponse {
275    match error {
276        DavLockEnforcementError::Backend(error) => {
277            into_response(crate::backend_error_response(&error))
278        }
279        DavLockEnforcementError::Conflict { path } => into_response(
280            crate::lock_conflict_response(prefix, &path)
281                .unwrap_or_else(|_| DavResponse::empty(http::StatusCode::INTERNAL_SERVER_ERROR)),
282        ),
283    }
284}
285
286/// Maps parent-collection enforcement failures to Actix at the handler boundary.
287#[must_use]
288pub fn parent_collection_error_response(error: DavParentCollectionError) -> HttpResponse {
289    match error {
290        DavParentCollectionError::MethodNotAllowed => into_response(
291            crate::mutation_plan_error_response(crate::DavMutationPlanError::MethodNotAllowed),
292        ),
293        DavParentCollectionError::Conflict => into_response(crate::mutation_plan_error_response(
294            crate::DavMutationPlanError::Conflict,
295        )),
296        DavParentCollectionError::Backend(error) => {
297            into_response(crate::backend_error_response(&error))
298        }
299    }
300}
301
302/// Copies Actix header types into the transport-neutral `http` 1.x map.
303///
304/// # Errors
305///
306/// Returns [`DavProtocolError`] when a header name or value cannot be converted.
307pub fn convert_header_map(source: &actix_header::HeaderMap) -> Result<HeaderMap, DavProtocolError> {
308    let mut headers = HeaderMap::with_capacity(source.len());
309    for (name, value) in source {
310        let name = HeaderName::from_bytes(name.as_str().as_bytes())
311            .map_err(|_| DavProtocolError::bad_request("Invalid request header"))?;
312        let value = HeaderValue::from_bytes(value.as_bytes())
313            .map_err(|_| DavProtocolError::bad_request("Invalid request header"))?;
314        headers.append(name, value);
315    }
316    Ok(headers)
317}
318
319/// Rejects the first non-empty request body chunk without buffering the remaining payload.
320///
321/// # Errors
322///
323/// Returns [`DavBodyError`] when payload reading fails or the body is not empty.
324pub async fn ensure_empty_body(payload: &mut actix_web::web::Payload) -> Result<(), DavBodyError> {
325    while let Some(chunk) = payload.next().await {
326        let chunk = chunk.map_err(|_| DavBodyError::ReadFailed)?;
327        if !chunk.is_empty() {
328            return Err(DavBodyError::BodyNotAllowed);
329        }
330    }
331    Ok(())
332}
333
334/// Collects a bounded request body for parsing by the protocol or product layer.
335///
336/// # Errors
337///
338/// Returns [`DavBodyError`] when reading fails or the configured maximum is exceeded.
339pub async fn collect_bounded_body(
340    payload: &mut actix_web::web::Payload,
341    maximum: usize,
342) -> Result<Vec<u8>, DavBodyError> {
343    let mut body = Vec::with_capacity(maximum.min(4096));
344    while let Some(chunk) = payload.next().await {
345        let chunk = chunk.map_err(|_| DavBodyError::ReadFailed)?;
346        let next_len = body
347            .len()
348            .checked_add(chunk.len())
349            .ok_or(DavBodyError::BodyTooLarge)?;
350        if next_len > maximum {
351            return Err(DavBodyError::BodyTooLarge);
352        }
353        body.extend_from_slice(&chunk);
354    }
355    Ok(body)
356}
357
358/// Applies an already planned body policy while leaving streaming bodies untouched.
359///
360/// # Errors
361///
362/// Returns [`DavBodyError`] when the planned body policy cannot be satisfied.
363pub async fn prepare_request_body(
364    policy: DavBodyPolicy,
365    payload: &mut actix_web::web::Payload,
366) -> Result<DavPreparedBody, DavBodyError> {
367    match policy {
368        DavBodyPolicy::Empty => ensure_empty_body(payload)
369            .await
370            .map(|()| DavPreparedBody::None),
371        DavBodyPolicy::BoundedXml { maximum } => collect_bounded_body(payload, maximum)
372            .await
373            .map(DavPreparedBody::Xml),
374        DavBodyPolicy::Bounded { maximum } => collect_bounded_body(payload, maximum)
375            .await
376            .map(DavPreparedBody::Bytes),
377        DavBodyPolicy::Stream | DavBodyPolicy::Unused => Ok(DavPreparedBody::None),
378    }
379}