Skip to main content

aster_forge_webdav/
protocol.rs

1//! WebDAV header parsing and protocol precondition rules.
2
3use std::time::{Duration, SystemTime};
4
5use http::header::{self, HeaderMap, HeaderValue};
6use http::uri::Authority;
7use http::{StatusCode, Uri};
8use percent_encoding::percent_decode_str;
9
10use crate::{
11    DavBackendError, DavFileSystem, DavIfResourceState, DavIfStateResolver, DavLockSystem, DavPath,
12    FsError,
13};
14use aster_forge_utils::http_validators;
15use async_trait::async_trait;
16
17/// WebDAV `Depth` header value.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Depth {
20    /// The request target only.
21    Zero,
22    /// The target and its immediate children.
23    One,
24    /// The complete descendant tree.
25    Infinity,
26}
27
28impl Depth {
29    /// Returns whether this depth traverses all descendants.
30    #[must_use]
31    pub fn is_infinity(self) -> bool {
32        matches!(self, Self::Infinity)
33    }
34}
35
36/// A parsed `Destination` header restricted to the current origin and mount.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Destination {
39    /// Canonical path relative to the WebDAV mount.
40    pub path: DavPath,
41    /// Decoded relative path retained for product adapters.
42    pub relative: String,
43}
44
45/// A parsed WebDAV `If` header.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct IfHeader {
48    /// Resource-tagged or untagged condition groups.
49    pub groups: Vec<IfResourceGroup>,
50}
51
52/// Conditions associated with one tagged resource or the request target.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct IfResourceGroup {
55    /// Tagged resource URI, or `None` for the request target.
56    pub tagged_path: Option<String>,
57    /// OR-connected state lists for this resource.
58    pub lists: Vec<IfStateList>,
59}
60
61/// AND-connected conditions inside one parenthesized state list.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct IfStateList {
64    /// State token and entity-tag conditions.
65    pub conditions: Vec<IfStateCondition>,
66}
67
68/// One WebDAV `If` condition.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum IfStateCondition {
71    /// A lock state token condition.
72    Token { value: String, negated: bool },
73    /// An entity-tag condition.
74    Etag { value: String, negated: bool },
75}
76
77/// Failure while resolving and evaluating a WebDAV `If` request precondition.
78#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
79pub enum DavIfEvaluationError {
80    #[error(transparent)]
81    Protocol(#[from] DavProtocolError),
82    #[error(transparent)]
83    Backend(#[from] DavBackendError),
84}
85
86/// Outcome of HTTP entity-tag and modification-date precondition evaluation.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum DavPrecondition {
89    /// Continue processing the request.
90    Proceed,
91    /// Return `304 Not Modified` for a safe method.
92    NotModified,
93}
94
95/// Stable protocol error classification for transport adapters.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum DavProtocolErrorKind {
98    BadRequest,
99    PreconditionFailed,
100}
101
102/// A product-neutral WebDAV protocol error.
103#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
104#[error("{message}")]
105pub struct DavProtocolError {
106    kind: DavProtocolErrorKind,
107    status: StatusCode,
108    message: &'static str,
109}
110
111impl DavProtocolError {
112    /// Returns the stable error category.
113    #[must_use]
114    pub fn kind(&self) -> DavProtocolErrorKind {
115        self.kind
116    }
117
118    /// Returns the HTTP status required by the protocol boundary.
119    #[must_use]
120    pub fn status(&self) -> StatusCode {
121        self.status
122    }
123
124    /// Returns a protocol-level response message.
125    #[must_use]
126    pub fn message(&self) -> &'static str {
127        self.message
128    }
129
130    pub(crate) fn bad_request(message: &'static str) -> Self {
131        Self {
132            kind: DavProtocolErrorKind::BadRequest,
133            status: StatusCode::BAD_REQUEST,
134            message,
135        }
136    }
137
138    fn precondition_failed() -> Self {
139        Self {
140            kind: DavProtocolErrorKind::PreconditionFailed,
141            status: StatusCode::PRECONDITION_FAILED,
142            message: "Precondition failed",
143        }
144    }
145}
146
147/// Parses the `Depth` semantics used by `PROPFIND`.
148pub fn parse_propfind_depth(headers: &HeaderMap) -> Result<Depth, DavProtocolError> {
149    match parse_depth_header(headers)? {
150        Some(Depth::Zero) => Ok(Depth::Zero),
151        Some(Depth::One) => Ok(Depth::One),
152        Some(Depth::Infinity) | None => Ok(Depth::Infinity),
153    }
154}
155
156/// Parses the `Depth` semantics used by `COPY`.
157pub fn parse_copy_depth(headers: &HeaderMap) -> Result<Depth, DavProtocolError> {
158    match parse_depth_header(headers)? {
159        Some(Depth::Zero) => Ok(Depth::Zero),
160        Some(Depth::Infinity) | None => Ok(Depth::Infinity),
161        Some(Depth::One) => Err(DavProtocolError::bad_request("Invalid Depth header")),
162    }
163}
164
165/// Parses the `Depth` semantics used by `MOVE`.
166pub fn parse_move_depth(headers: &HeaderMap) -> Result<Depth, DavProtocolError> {
167    Ok(parse_depth_header(headers)?.unwrap_or(Depth::Infinity))
168}
169
170/// Parses the `Depth` semantics used by `DELETE`.
171pub fn parse_delete_depth(headers: &HeaderMap) -> Result<Depth, DavProtocolError> {
172    Ok(parse_depth_header(headers)?.unwrap_or(Depth::Infinity))
173}
174
175/// Parses the `Depth` semantics used by `LOCK`.
176pub fn parse_lock_depth(headers: &HeaderMap) -> Result<Depth, DavProtocolError> {
177    match parse_depth_header(headers)? {
178        None | Some(Depth::Infinity) => Ok(Depth::Infinity),
179        Some(Depth::Zero) => Ok(Depth::Zero),
180        Some(Depth::One) => Err(DavProtocolError::bad_request("Invalid Depth header")),
181    }
182}
183
184fn parse_depth_header(headers: &HeaderMap) -> Result<Option<Depth>, DavProtocolError> {
185    let Some(value) = headers.get("Depth") else {
186        return Ok(None);
187    };
188    let value = value
189        .to_str()
190        .map_err(|_| DavProtocolError::bad_request("Invalid Depth header"))?;
191
192    match value {
193        value if value.eq_ignore_ascii_case("0") => Ok(Some(Depth::Zero)),
194        value if value.eq_ignore_ascii_case("1") => Ok(Some(Depth::One)),
195        value if value.eq_ignore_ascii_case("infinity") => Ok(Some(Depth::Infinity)),
196        _ => Err(DavProtocolError::bad_request("Invalid Depth header")),
197    }
198}
199
200/// Parses `Overwrite`, defaulting to `true` when the header is absent.
201pub fn parse_overwrite(headers: &HeaderMap) -> Result<bool, DavProtocolError> {
202    let Some(value) = headers.get("Overwrite") else {
203        return Ok(true);
204    };
205    let value = value
206        .to_str()
207        .map_err(|_| DavProtocolError::bad_request("Invalid Overwrite header"))?
208        .trim();
209    if value.eq_ignore_ascii_case("T") {
210        Ok(true)
211    } else if value.eq_ignore_ascii_case("F") {
212        Ok(false)
213    } else {
214        Err(DavProtocolError::bad_request("Invalid Overwrite header"))
215    }
216}
217
218/// Parses and constrains `Destination` to the current origin and WebDAV mount.
219pub fn destination_relative_path(
220    headers: &HeaderMap,
221    prefix: &str,
222    request_scheme: &str,
223    request_host: &str,
224) -> Result<Destination, DavProtocolError> {
225    let raw = headers
226        .get("Destination")
227        .ok_or_else(|| DavProtocolError::bad_request("Missing Destination header"))?
228        .to_str()
229        .map_err(|_| DavProtocolError::bad_request("Invalid Destination header"))?
230        .trim();
231    let uri: Uri = raw
232        .parse()
233        .map_err(|_| DavProtocolError::bad_request("Invalid Destination header"))?;
234    match (uri.scheme_str(), uri.authority()) {
235        (Some(scheme), Some(authority)) => {
236            if !origin_authority_matches(scheme, authority, request_scheme, request_host) {
237                return Err(DavProtocolError::bad_request(
238                    "Destination must stay on this WebDAV server",
239                ));
240            }
241        }
242        (None, None) => {
243            if !raw.starts_with('/') {
244                return Err(DavProtocolError::bad_request("Invalid Destination header"));
245            }
246        }
247        _ => return Err(DavProtocolError::bad_request("Invalid Destination header")),
248    }
249
250    let path = uri.path();
251    let relative = strip_mount_prefix(path, prefix).ok_or_else(|| {
252        DavProtocolError::bad_request("Destination must stay under WebDAV prefix")
253    })?;
254    let path = DavPath::new(relative)
255        .map_err(|_| DavProtocolError::bad_request("Invalid Destination header"))?;
256    let relative = path.as_str().to_string();
257    Ok(Destination { path, relative })
258}
259
260/// Parses a WebDAV `If` header.
261pub fn parse_if_header(headers: &HeaderMap) -> Result<Option<IfHeader>, DavProtocolError> {
262    let Some(value) = headers.get("If") else {
263        return Ok(None);
264    };
265    let raw = value
266        .to_str()
267        .map_err(|_| DavProtocolError::bad_request("Invalid If header"))?;
268    IfHeaderParser::new(raw).parse().map(Some)
269}
270
271/// Resolves referenced resources and enforces the complete WebDAV `If` state-list precondition.
272///
273/// Conditions inside one list are AND-connected. Lists for one resource and tagged resource
274/// productions across the complete header are OR-connected.
275pub async fn enforce_if_header(
276    if_header: Option<&IfHeader>,
277    resolver: &dyn DavIfStateResolver,
278    request_path: &DavPath,
279    prefix: &str,
280    request_scheme: &str,
281    request_host: &str,
282) -> Result<(), DavIfEvaluationError> {
283    let Some(if_header) = if_header else {
284        return Ok(());
285    };
286
287    for group in &if_header.groups {
288        let path = match group.tagged_path.as_deref() {
289            Some(tagged_path) => {
290                tagged_dav_path(prefix, tagged_path, request_scheme, request_host)?
291            }
292            None => Some(request_path.clone()),
293        };
294        let state = match path.as_ref() {
295            Some(path) => resolver.resolve_if_state(path).await?,
296            None => DavIfResourceState::default(),
297        };
298        if group
299            .lists
300            .iter()
301            .any(|list| evaluate_if_state_list(list, &state))
302        {
303            return Ok(());
304        }
305    }
306    Err(DavProtocolError::precondition_failed().into())
307}
308
309/// Resolves `If` state from the canonical filesystem and lock backend ports.
310///
311/// Products normally use this entrypoint instead of implementing a transport-local resolver.
312/// A missing resource contributes no ETag, while all other filesystem failures preserve the
313/// backend error boundary.
314pub async fn enforce_if_header_with_backends(
315    if_header: Option<&IfHeader>,
316    filesystem: &dyn DavFileSystem,
317    lock_system: &dyn DavLockSystem,
318    request_path: &DavPath,
319    prefix: &str,
320    request_scheme: &str,
321    request_host: &str,
322) -> Result<(), DavIfEvaluationError> {
323    let resolver = BackendIfStateResolver {
324        filesystem,
325        lock_system,
326    };
327    enforce_if_header(
328        if_header,
329        &resolver,
330        request_path,
331        prefix,
332        request_scheme,
333        request_host,
334    )
335    .await
336}
337
338struct BackendIfStateResolver<'a> {
339    filesystem: &'a dyn DavFileSystem,
340    lock_system: &'a dyn DavLockSystem,
341}
342
343#[async_trait]
344impl DavIfStateResolver for BackendIfStateResolver<'_> {
345    async fn resolve_if_state(
346        &self,
347        path: &DavPath,
348    ) -> Result<DavIfResourceState, DavBackendError> {
349        let etag = match self.filesystem.metadata(path).await {
350            Ok(metadata) => metadata.etag(),
351            Err(FsError::NotFound) => None,
352            Err(error) => return Err(error.into()),
353        };
354        let lock_tokens = self
355            .lock_system
356            .discover(path)
357            .await
358            .into_iter()
359            .map(|lock| lock.token)
360            .collect();
361        Ok(DavIfResourceState { etag, lock_tokens })
362    }
363}
364
365fn evaluate_if_state_list(list: &IfStateList, state: &DavIfResourceState) -> bool {
366    list.conditions.iter().all(|condition| match condition {
367        IfStateCondition::Token { value, negated } => {
368            state.lock_tokens.iter().any(|token| token == value) ^ *negated
369        }
370        IfStateCondition::Etag { value, negated } => {
371            state.etag.as_deref().is_some_and(|etag| {
372                http_validators::if_none_match_header_matches(value, true, Some(etag))
373                    .unwrap_or(false)
374            }) ^ *negated
375        }
376    })
377}
378
379fn tagged_dav_path(
380    prefix: &str,
381    tagged_path: &str,
382    request_scheme: &str,
383    request_host: &str,
384) -> Result<Option<DavPath>, DavProtocolError> {
385    let uri: Uri = tagged_path
386        .parse()
387        .map_err(|_| DavProtocolError::bad_request("Invalid If header"))?;
388    let path = match (uri.scheme_str(), uri.authority()) {
389        (Some(scheme), Some(authority)) => {
390            if !origin_authority_matches(scheme, authority, request_scheme, request_host) {
391                return Ok(None);
392            }
393            uri.path()
394        }
395        (None, None) => uri.path(),
396        _ => return Err(DavProtocolError::bad_request("Invalid If header")),
397    };
398    if !path.starts_with('/') {
399        return Err(DavProtocolError::bad_request("Invalid If header"));
400    }
401    let Some(relative) = strip_mount_prefix(path, prefix) else {
402        return Ok(None);
403    };
404    DavPath::new(relative)
405        .map(Some)
406        .map_err(|_| DavProtocolError::bad_request("Invalid If header"))
407}
408
409/// Extracts submitted lock tokens that apply to one request path.
410pub fn submitted_lock_tokens_for_path(
411    headers: &HeaderMap,
412    request_path: &str,
413    request_scheme: &str,
414    request_host: &str,
415) -> Vec<String> {
416    let Some(if_header) = parse_if_header(headers).ok().flatten() else {
417        return Vec::new();
418    };
419    submitted_lock_tokens(&if_header, request_path, request_scheme, request_host)
420}
421
422/// Extracts submitted lock tokens from an already parsed `If` header.
423#[must_use]
424pub fn submitted_lock_tokens(
425    if_header: &IfHeader,
426    request_path: &str,
427    request_scheme: &str,
428    request_host: &str,
429) -> Vec<String> {
430    let mut tokens = Vec::new();
431    for group in &if_header.groups {
432        match group.tagged_path.as_deref() {
433            None => {}
434            Some(tagged_path)
435                if if_tag_matches_path(tagged_path, request_path, request_scheme, request_host) => {
436            }
437            Some(_) => continue,
438        }
439        for list in &group.lists {
440            for condition in &list.conditions {
441                if let IfStateCondition::Token { value, .. } = condition {
442                    tokens.push(value.clone());
443                }
444            }
445        }
446    }
447    tokens.sort();
448    tokens.dedup();
449    tokens
450}
451
452/// Parses a bounded LOCK timeout using a product-supplied maximum duration.
453pub fn parse_lock_timeout(
454    headers: &HeaderMap,
455    maximum: Duration,
456) -> Result<Duration, DavProtocolError> {
457    let Some(value) = headers.get("Timeout") else {
458        return Ok(maximum);
459    };
460    let raw = value
461        .to_str()
462        .map_err(|_| DavProtocolError::bad_request("Invalid Timeout header"))?;
463    for candidate in raw
464        .split(',')
465        .map(str::trim)
466        .filter(|value| !value.is_empty())
467    {
468        if candidate.eq_ignore_ascii_case("Infinite") {
469            return Ok(maximum);
470        }
471        if let Some(seconds) = candidate
472            .strip_prefix("Second-")
473            .and_then(|seconds| seconds.parse::<u64>().ok())
474        {
475            return Ok(Duration::from_secs(seconds).min(maximum));
476        }
477    }
478    Err(DavProtocolError::bad_request("Invalid Timeout header"))
479}
480
481/// Parses the required angle-bracketed `Lock-Token` request header.
482pub fn parse_lock_token_header(headers: &HeaderMap) -> Result<String, DavProtocolError> {
483    let raw = headers
484        .get("Lock-Token")
485        .ok_or_else(|| DavProtocolError::bad_request("Missing Lock-Token header"))?
486        .to_str()
487        .map_err(|_| DavProtocolError::bad_request("Invalid Lock-Token header"))?
488        .trim();
489    let token = raw
490        .strip_prefix('<')
491        .and_then(|value| value.strip_suffix('>'))
492        .filter(|token| !token.is_empty() && !token.contains(['<', '>']))
493        .ok_or_else(|| DavProtocolError::bad_request("Invalid Lock-Token header"))?;
494    Ok(token.to_owned())
495}
496
497/// Evaluates `If-Match` and `If-None-Match` for a resource operation.
498pub fn evaluate_http_etag_preconditions(
499    headers: &HeaderMap,
500    resource_exists: bool,
501    current_etag: Option<&str>,
502    safe_method: bool,
503) -> Result<DavPrecondition, DavProtocolError> {
504    if let Some(value) = headers.get(header::IF_MATCH) {
505        let raw = value
506            .to_str()
507            .map_err(|_| DavProtocolError::bad_request("Invalid If-Match header"))?;
508        if !http_validators::if_match_header_matches(raw, resource_exists, current_etag)
509            .map_err(|_| DavProtocolError::bad_request("Invalid If-Match header"))?
510        {
511            return Err(DavProtocolError::precondition_failed());
512        }
513    }
514
515    if let Some(value) = headers.get(header::IF_NONE_MATCH) {
516        let raw = value
517            .to_str()
518            .map_err(|_| DavProtocolError::bad_request("Invalid If-None-Match header"))?;
519        if http_validators::if_none_match_header_matches(raw, resource_exists, current_etag)
520            .map_err(|_| DavProtocolError::bad_request("Invalid If-None-Match header"))?
521        {
522            return if safe_method {
523                Ok(DavPrecondition::NotModified)
524            } else {
525                Err(DavProtocolError::precondition_failed())
526            };
527        }
528    }
529
530    Ok(DavPrecondition::Proceed)
531}
532
533/// Evaluates RFC 7232 download preconditions in their required precedence order.
534pub fn evaluate_http_download_preconditions(
535    headers: &HeaderMap,
536    current_etag: Option<&str>,
537    last_modified: Option<SystemTime>,
538) -> Result<DavPrecondition, DavProtocolError> {
539    let has_if_match = headers.contains_key(header::IF_MATCH);
540    if let Some(value) = headers.get(header::IF_MATCH) {
541        let raw = value
542            .to_str()
543            .map_err(|_| DavProtocolError::bad_request("Invalid If-Match header"))?;
544        if !http_validators::if_match_header_matches(raw, true, current_etag)
545            .map_err(|_| DavProtocolError::bad_request("Invalid If-Match header"))?
546        {
547            return Err(DavProtocolError::precondition_failed());
548        }
549    }
550
551    if !has_if_match
552        && let (Some(value), Some(last_modified)) =
553            (headers.get(header::IF_UNMODIFIED_SINCE), last_modified)
554    {
555        let since = parse_http_date_header(value, "Invalid If-Unmodified-Since header")?;
556        if http_validators::http_date_epoch_seconds(last_modified)
557            > http_validators::http_date_epoch_seconds(since)
558        {
559            return Err(DavProtocolError::precondition_failed());
560        }
561    }
562
563    let has_if_none_match = headers.contains_key(header::IF_NONE_MATCH);
564    if let Some(value) = headers.get(header::IF_NONE_MATCH) {
565        let raw = value
566            .to_str()
567            .map_err(|_| DavProtocolError::bad_request("Invalid If-None-Match header"))?;
568        if http_validators::if_none_match_header_matches(raw, true, current_etag)
569            .map_err(|_| DavProtocolError::bad_request("Invalid If-None-Match header"))?
570        {
571            return Ok(DavPrecondition::NotModified);
572        }
573    }
574
575    if !has_if_none_match
576        && let (Some(value), Some(last_modified)) =
577            (headers.get(header::IF_MODIFIED_SINCE), last_modified)
578    {
579        let since = parse_http_date_header(value, "Invalid If-Modified-Since header")?;
580        if http_validators::http_date_epoch_seconds(last_modified)
581            <= http_validators::http_date_epoch_seconds(since)
582        {
583            return Ok(DavPrecondition::NotModified);
584        }
585    }
586
587    Ok(DavPrecondition::Proceed)
588}
589
590fn parse_http_date_header(
591    value: &HeaderValue,
592    invalid_message: &'static str,
593) -> Result<SystemTime, DavProtocolError> {
594    let raw = value
595        .to_str()
596        .map_err(|_| DavProtocolError::bad_request(invalid_message))?;
597    http_validators::parse_http_date(raw)
598        .map_err(|_| DavProtocolError::bad_request(invalid_message))
599}
600
601fn origin_authority_matches(
602    uri_scheme: &str,
603    uri_authority: &Authority,
604    request_scheme: &str,
605    request_host: &str,
606) -> bool {
607    if !uri_scheme.eq_ignore_ascii_case(request_scheme) {
608        return false;
609    }
610    let Ok(request_authority) = request_host.parse::<Authority>() else {
611        return false;
612    };
613    if uri_authority.as_str().contains('@') || request_authority.as_str().contains('@') {
614        return false;
615    }
616    uri_authority
617        .host()
618        .eq_ignore_ascii_case(request_authority.host())
619        && effective_port(uri_scheme, uri_authority)
620            == effective_port(request_scheme, &request_authority)
621}
622
623fn effective_port(scheme: &str, authority: &Authority) -> Option<u16> {
624    authority.port_u16().or_else(|| {
625        if scheme.eq_ignore_ascii_case("http") {
626            Some(80)
627        } else if scheme.eq_ignore_ascii_case("https") {
628            Some(443)
629        } else {
630            None
631        }
632    })
633}
634
635pub(crate) fn strip_mount_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
636    path.strip_prefix(prefix).filter(|_| {
637        prefix == "/"
638            || path == prefix
639            || path
640                .as_bytes()
641                .get(prefix.len())
642                .is_some_and(|byte| *byte == b'/')
643    })
644}
645
646fn normalize_lock_token(value: &str) -> String {
647    value
648        .trim()
649        .trim_matches(|character| character == '<' || character == '>')
650        .to_string()
651}
652
653struct IfHeaderParser<'a> {
654    input: &'a str,
655    position: usize,
656}
657
658impl<'a> IfHeaderParser<'a> {
659    fn new(input: &'a str) -> Self {
660        Self { input, position: 0 }
661    }
662
663    fn parse(&mut self) -> Result<IfHeader, DavProtocolError> {
664        self.skip_linear_whitespace();
665        if self.is_eof() {
666            return Err(DavProtocolError::bad_request("Invalid If header"));
667        }
668
669        let tagged = self.peek_char() == Some('<');
670        let mut groups = Vec::new();
671        if tagged {
672            while !self.is_eof() {
673                let tagged_path = self.parse_angle_value()?;
674                let mut lists = Vec::new();
675                loop {
676                    self.skip_linear_whitespace();
677                    if self.peek_char() != Some('(') {
678                        break;
679                    }
680                    lists.push(self.parse_state_list()?);
681                }
682                if lists.is_empty() {
683                    return Err(DavProtocolError::bad_request("Invalid If header"));
684                }
685                groups.push(IfResourceGroup {
686                    tagged_path: Some(tagged_path),
687                    lists,
688                });
689                self.skip_linear_whitespace();
690                if self.is_eof() {
691                    break;
692                }
693                if self.peek_char() != Some('<') {
694                    return Err(DavProtocolError::bad_request("Invalid If header"));
695                }
696            }
697        } else {
698            let mut lists = Vec::new();
699            while !self.is_eof() {
700                lists.push(self.parse_state_list()?);
701                self.skip_linear_whitespace();
702                if self.peek_char() == Some('<') {
703                    return Err(DavProtocolError::bad_request("Invalid If header"));
704                }
705            }
706            groups.push(IfResourceGroup {
707                tagged_path: None,
708                lists,
709            });
710        }
711        Ok(IfHeader { groups })
712    }
713
714    fn parse_state_list(&mut self) -> Result<IfStateList, DavProtocolError> {
715        self.expect_char('(')?;
716        let mut conditions = Vec::new();
717        loop {
718            self.skip_linear_whitespace();
719            if self.peek_char() == Some(')') {
720                self.position += 1;
721                break;
722            }
723            if self.is_eof() {
724                return Err(DavProtocolError::bad_request("Invalid If header"));
725            }
726
727            let negated = self.consume_not();
728            self.skip_linear_whitespace();
729            let condition = match self.peek_char() {
730                Some('<') => IfStateCondition::Token {
731                    value: normalize_lock_token(&self.parse_angle_value()?),
732                    negated,
733                },
734                Some('[') => IfStateCondition::Etag {
735                    value: self.parse_bracket_value()?,
736                    negated,
737                },
738                _ => return Err(DavProtocolError::bad_request("Invalid If header")),
739            };
740            conditions.push(condition);
741        }
742
743        if conditions.is_empty() {
744            return Err(DavProtocolError::bad_request("Invalid If header"));
745        }
746        Ok(IfStateList { conditions })
747    }
748
749    fn parse_angle_value(&mut self) -> Result<String, DavProtocolError> {
750        self.parse_delimited('<', '>')
751    }
752
753    fn parse_bracket_value(&mut self) -> Result<String, DavProtocolError> {
754        self.parse_delimited('[', ']')
755    }
756
757    fn parse_delimited(
758        &mut self,
759        opening: char,
760        closing: char,
761    ) -> Result<String, DavProtocolError> {
762        self.expect_char(opening)?;
763        let start = self.position;
764        while let Some(character) = self.peek_char() {
765            if character == closing {
766                let value = self.input[start..self.position].trim();
767                self.position += closing.len_utf8();
768                if value.is_empty() {
769                    return Err(DavProtocolError::bad_request("Invalid If header"));
770                }
771                return Ok(value.to_string());
772            }
773            self.position += character.len_utf8();
774        }
775        Err(DavProtocolError::bad_request("Invalid If header"))
776    }
777
778    fn consume_not(&mut self) -> bool {
779        let rest = &self.input[self.position..];
780        let Some(candidate) = rest.get(..3) else {
781            return false;
782        };
783        if !candidate.eq_ignore_ascii_case("not") {
784            return false;
785        }
786        let after_not = &rest[3..];
787        if after_not.chars().next().is_some_and(|character| {
788            !character.is_ascii_whitespace() && character != '<' && character != '['
789        }) {
790            return false;
791        }
792        self.position += 3;
793        true
794    }
795
796    fn expect_char(&mut self, expected: char) -> Result<(), DavProtocolError> {
797        if self.peek_char() == Some(expected) {
798            self.position += expected.len_utf8();
799            Ok(())
800        } else {
801            Err(DavProtocolError::bad_request("Invalid If header"))
802        }
803    }
804
805    fn skip_linear_whitespace(&mut self) {
806        while self
807            .peek_char()
808            .is_some_and(|character| matches!(character, ' ' | '\t' | '\r' | '\n'))
809        {
810            self.position += 1;
811        }
812    }
813
814    fn peek_char(&self) -> Option<char> {
815        self.input[self.position..].chars().next()
816    }
817
818    fn is_eof(&self) -> bool {
819        self.position >= self.input.len()
820    }
821}
822
823fn if_tag_matches_path(
824    tagged_path: &str,
825    request_path: &str,
826    request_scheme: &str,
827    request_host: &str,
828) -> bool {
829    if path_equivalent(tagged_path, request_path) {
830        return true;
831    }
832    let Ok(uri) = tagged_path.parse::<Uri>() else {
833        return false;
834    };
835    match (uri.scheme_str(), uri.authority()) {
836        (Some(scheme), Some(authority)) => {
837            origin_authority_matches(scheme, authority, request_scheme, request_host)
838                && path_equivalent(uri.path(), request_path)
839        }
840        (None, None) => path_equivalent(uri.path(), request_path),
841        _ => false,
842    }
843}
844
845fn path_equivalent(left: &str, right: &str) -> bool {
846    if left == right {
847        return true;
848    }
849    let left_decoded = percent_decode_str(left).decode_utf8().ok();
850    let right_decoded = percent_decode_str(right).decode_utf8().ok();
851    match (left_decoded.as_deref(), right_decoded.as_deref()) {
852        (Some(left), Some(right)) => left == right,
853        (Some(left), None) => left == right,
854        (None, Some(right)) => left == right,
855        (None, None) => false,
856    }
857}