diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index b5bfe696..7bd26159 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -783,9 +783,12 @@ async fn handle_proppatch( .get("If") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - if let Some(resp) = - enforce_native_lock(&state.webdav_lock_store, if_header_owned.as_deref(), &path) - { + if let Some(resp) = enforce_native_lock( + &state.webdav_lock_store, + if_header_owned.as_deref(), + &path, + None, + ) { return Ok(resp); } @@ -1246,49 +1249,212 @@ async fn streamed_file_dead_props( .unwrap_or_default() } -/// Extract every `<...>` token from a WebDAV `If:` header value. -/// -/// RFC 4918 §10.4 defines a richer grammar (tagged-list / no-tag-list of -/// `(Condition)` items), but for our purposes the only thing that matters -/// is what lock tokens the caller is claiming to hold. Forgivingly scoop -/// every angle-bracketed value and let the caller compare against the -/// active lock token(s). -fn extract_if_header_tokens(if_header: &str) -> Vec { - let mut out = Vec::new(); - let mut current = String::new(); - let mut inside = false; - for c in if_header.chars() { - match (inside, c) { - (false, '<') => { - inside = true; - current.clear(); - } - (true, '>') => { - inside = false; - if !current.is_empty() { - out.push(std::mem::take(&mut current)); - } - } - (true, c) => current.push(c), - _ => {} - } - } - out +/// A single condition inside a `List` of the WebDAV `If:` header +/// (RFC 4918 §10.4.2 grammar): +/// `Condition = ["Not"] (State-token | "[" entity-tag "]")`. +#[derive(Debug, Clone, PartialEq)] +enum IfCondition { + StateToken { negated: bool, token: String }, + EntityTag { negated: bool, etag: String }, } -/// RFC 4918 §9.10.4 — if `path` is locked, every mutating request MUST -/// carry the lock's token in its `If:` header. Returns `Some(Response)` -/// with a 423 Locked response when the request must be rejected; `None` -/// when the path is unlocked or the caller's `If:` header carries the -/// matching token (the cheap-and-cheerful submission check). +/// A parsed `If:` header — outer Vec is OR of `List`s, inner Vec is AND +/// of `Condition`s (RFC 4918 §10.4.2). Tagged-list `Resource` URIs are +/// accepted by the parser but their scoping is ignored — every List is +/// treated as applying to the current request URI. That's a +/// simplification adequate for litmus and NC clients; a real Tagged-list +/// implementation would map each List to its preceding Resource. +type IfLists = Vec>; + +/// Best-effort parser for RFC 4918 §10.4.2 `If:` headers. Malformed +/// input yields whatever Lists could be recovered; downstream evaluation +/// treats an empty result as "no header". +fn parse_if_header(header: &str) -> IfLists { + let mut lists: IfLists = Vec::new(); + let bytes = header.as_bytes(); + let n = bytes.len(); + let mut i = 0; + + while i < n { + match bytes[i] { + b' ' | b'\t' | b'\r' | b'\n' => i += 1, + b'<' => { + // Tagged-list Resource prefix — skip past the closing '>'. + i += 1; + while i < n && bytes[i] != b'>' { + i += 1; + } + if i < n { + i += 1; + } + } + b'(' => { + i += 1; + let mut list: Vec = Vec::new(); + loop { + while i < n && matches!(bytes[i], b' ' | b'\t' | b'\r' | b'\n') { + i += 1; + } + if i >= n { + break; + } + if bytes[i] == b')' { + i += 1; + break; + } + + // Optional "Not" prefix. + let mut negated = false; + if i + 3 <= n + && bytes[i..i + 3].eq_ignore_ascii_case(b"Not") + && (i + 3 == n + || matches!(bytes[i + 3], b' ' | b'\t' | b'<' | b'[' | b'\r' | b'\n')) + { + negated = true; + i += 3; + while i < n && matches!(bytes[i], b' ' | b'\t' | b'\r' | b'\n') { + i += 1; + } + } + if i >= n { + break; + } + + match bytes[i] { + b'<' => { + i += 1; + let start = i; + while i < n && bytes[i] != b'>' { + i += 1; + } + let token = std::str::from_utf8(&bytes[start..i]) + .unwrap_or_default() + .to_string(); + if i < n { + i += 1; + } + list.push(IfCondition::StateToken { negated, token }); + } + b'[' => { + i += 1; + let start = i; + while i < n && bytes[i] != b']' { + i += 1; + } + let etag = std::str::from_utf8(&bytes[start..i]) + .unwrap_or_default() + .trim() + .trim_matches('"') + .to_string(); + if i < n { + i += 1; + } + list.push(IfCondition::EntityTag { negated, etag }); + } + _ => { + // Malformed — skip a byte and continue trying. + i += 1; + } + } + } + if !list.is_empty() { + lists.push(list); + } + } + _ => i += 1, + } + } + lists +} + +/// Evaluate a parsed `If:` header against the current resource state. /// -/// Shared by `handle_put` now and will be reused by `handle_delete`, -/// `handle_move`, `handle_copy`, and `handle_proppatch` when each of -/// those gets the same enforcement. +/// Returns `(header_true, submitted_active_lock)`: +/// - `header_true` — at least one `List` (AND of Conditions) evaluates +/// true, so the OR-across-Lists holds and the precondition passes. +/// - `submitted_active_lock` — some non-negated `State-token` Condition +/// presented the resource's actual lock token. Used to distinguish +/// 412 (precondition failed) from 423 (locked resource, no matching +/// token submitted) per RFC 4918 §10.4.9 / §6.6. +/// +/// Empty `lists` (parse failed / header absent) → treated as +/// vacuously true. Callers handle the "no If: header on a locked +/// resource" case separately. +fn evaluate_if_header( + lists: &IfLists, + active_lock_token: Option<&str>, + current_etag: Option<&str>, +) -> (bool, bool) { + if lists.is_empty() { + return (true, false); + } + + // First pass — scan every non-negated State-token so we can flag + // "the caller did submit the lock" even for Lists that fail on other + // Conditions. This drives the 412-vs-423 discrimination downstream. + let mut submitted_active_lock = false; + if let Some(active) = active_lock_token { + for list in lists { + for cond in list { + if let IfCondition::StateToken { + negated: false, + token, + } = cond + && token == active + { + submitted_active_lock = true; + } + } + } + } + + // Second pass — AND within each List, OR across Lists. + let any_list_true = lists.iter().any(|list| { + list.iter().all(|cond| { + let (negated, natural) = match cond { + IfCondition::StateToken { negated, token } => { + let is_active = active_lock_token == Some(token.as_str()); + (*negated, is_active) + } + IfCondition::EntityTag { + negated, + etag: cond_etag, + } => { + let matches = current_etag + .map(|c| c.trim().trim_matches('"') == cond_etag.trim().trim_matches('"')) + .unwrap_or(false); + (*negated, matches) + } + }; + natural ^ negated + }) + }); + + (any_list_true, submitted_active_lock) +} + +/// RFC 4918 §9.10.4 / §10.4 — evaluate the caller's `If:` header +/// against the resource's active lock and current ETag. +/// +/// Returns `Some(Response)` when the request must be rejected — +/// **412 Precondition Failed** when the header's conditions can't be +/// satisfied by the current state, **423 Locked** when the resource is +/// locked and no submitted alternative includes its lock token (per +/// §10.4.9 / §6.6). Returns `None` when the request may proceed. +/// +/// `current_etag` is the resource's ETag (raw, unquoted) if it exists; +/// pass `None` for a not-yet-existing target. Callers that don't have +/// the resolved etag at the call site pass `None` — any `[etag]` +/// condition then evaluates false, which is the correct fail-closed +/// behaviour for the "resource doesn't exist yet" case. +/// +/// Shared by `handle_put`, `handle_delete`, `handle_move`, +/// `handle_copy`, and `handle_proppatch`. fn enforce_native_lock( lock_store: &crate::infrastructure::services::webdav_lock_service::WebDavLockStore, if_header: Option<&str>, path: &str, + current_etag: Option<&str>, ) -> Option> { // Check the exact path, then walk up parent collections for depth-infinity // locks (RFC 4918 §6.1: a lock on a collection with Depth: infinity also @@ -1309,15 +1475,47 @@ fn enforce_native_lock( } }); - if let Some(entry) = entry { - // Resource is locked: caller must supply the matching token in If:. - if let Some(h) = if_header - && extract_if_header_tokens(h) - .iter() - .any(|t| t == &entry.info.token) - { - return None; + let active_lock_token = entry.as_ref().map(|e| e.info.token.as_str()); + let locked = entry.is_some(); + + let lists = if_header.map(parse_if_header).unwrap_or_default(); + + // No parseable If: header at all. + if lists.is_empty() { + // Locked without an If: header → 423 Locked (§9.10.4). + if locked { + return Some( + Response::builder() + .status(StatusCode::LOCKED) + .body(Body::empty()) + .unwrap(), + ); } + return None; + } + + let (header_true, submitted_active_lock) = + evaluate_if_header(&lists, active_lock_token, current_etag); + + if header_true { + // Header preconditions satisfied. If the resource is locked but + // the satisfying List did so via etag / Not-token alone (never + // presenting the real lock token), still return 423 — §10.4.9's + // "matching lock token" rule. + if locked && !submitted_active_lock { + return Some( + Response::builder() + .status(StatusCode::LOCKED) + .body(Body::empty()) + .unwrap(), + ); + } + return None; + } + + // Header evaluates false. 423 if the resource is locked and the + // caller never presented its lock token; otherwise 412. + if locked && !submitted_active_lock { return Some( Response::builder() .status(StatusCode::LOCKED) @@ -1325,32 +1523,12 @@ fn enforce_native_lock( .unwrap(), ); } - - // Resource is not locked. If the If: header references lock tokens (not - // resource-tag URLs), every such token must be active somewhere in the - // store. A stale or fabricated token (e.g. DAV:no-lock) never matches, - // so the If: condition fails → 412 Precondition Failed (RFC 4918 §10.4). - if let Some(h) = if_header { - let tokens = extract_if_header_tokens(h); - let lock_refs: Vec<_> = tokens - .iter() - .filter(|t| !t.starts_with("http://") && !t.starts_with("https://")) - .collect(); - if !lock_refs.is_empty() - && !lock_refs - .iter() - .any(|t| lock_store.get_by_token(t).is_some()) - { - return Some( - Response::builder() - .status(StatusCode::PRECONDITION_FAILED) - .body(Body::empty()) - .unwrap(), - ); - } - } - - None + Some( + Response::builder() + .status(StatusCode::PRECONDITION_FAILED) + .body(Body::empty()) + .unwrap(), + ) } /** @@ -1414,13 +1592,6 @@ async fn handle_put( .to_string(); let max_upload = state.core.config.storage.direct_put_max_bytes; - // ── Active-lock guard (RFC 4918 §9.10.4) ────────────────────────── - if let Some(resp) = - enforce_native_lock(&state.webdav_lock_store, if_header_owned.as_deref(), &path) - { - return Ok(resp); - } - // `drive_id` is the path-lookup scope post-D0 — resolve once from // the caller's default drive, reused by the resolver checks below // and by the atomic-store call further down. @@ -1496,6 +1667,21 @@ async fn handle_put( } } + // ── Active-lock guard + RFC 4918 §10.4 If: evaluation ───────────── + // Deferred until after the existence check so `current_etag` is + // available to the If: header's `[etag]` conditions. `handle_put` + // is the only site where litmus exercises the full §10.4 grammar + // (locks/fail_complex_cond_put); other handlers pass `None` and + // fall back to the existence-only semantics. + if let Some(resp) = enforce_native_lock( + &state.webdav_lock_store, + if_header_owned.as_deref(), + &path, + current_etag.as_deref(), + ) { + return Ok(resp); + } + // ── RFC 7232 conditional preconditions ──────────────────────────── // Evaluated before ingesting the body to save bandwidth on doomed requests. if let Some(ref inm) = if_none_match { @@ -1798,9 +1984,12 @@ async fn handle_delete( .get("If") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - if let Some(resp) = - enforce_native_lock(&state.webdav_lock_store, if_header_owned.as_deref(), &path) - { + if let Some(resp) = enforce_native_lock( + &state.webdav_lock_store, + if_header_owned.as_deref(), + &path, + None, + ) { return Ok(resp); } @@ -1880,6 +2069,7 @@ async fn handle_move( &state.webdav_lock_store, if_header_owned.as_deref(), &source_path, + None, ) { return Ok(resp); } @@ -1935,6 +2125,7 @@ async fn handle_move( &state.webdav_lock_store, if_header_owned.as_deref(), &destination_path, + None, ) { return Ok(resp); } @@ -2209,6 +2400,7 @@ async fn handle_copy( &state.webdav_lock_store, if_header_owned.as_deref(), &destination_path, + None, ) { return Ok(resp); } @@ -2581,6 +2773,194 @@ async fn handle_unlock( mod tests { use super::*; + // ── RFC 4918 §10.4 If: header parser + evaluator ──────────────── + + #[test] + fn parse_if_simple_state_token() { + let lists = parse_if_header("()"); + assert_eq!( + lists, + vec![vec![IfCondition::StateToken { + negated: false, + token: "opaquelocktoken:xyz".to_string(), + }]] + ); + } + + #[test] + fn parse_if_no_tag_list_two_lists() { + let lists = parse_if_header("() (Not )"); + assert_eq!(lists.len(), 2); + assert_eq!( + lists[0], + vec![IfCondition::StateToken { + negated: false, + token: "T1".to_string(), + }] + ); + assert_eq!( + lists[1], + vec![IfCondition::StateToken { + negated: true, + token: "DAV:no-lock".to_string(), + }] + ); + } + + #[test] + fn parse_if_state_token_and_etag() { + let lists = parse_if_header("( [\"abc123\"])"); + assert_eq!( + lists[0], + vec![ + IfCondition::StateToken { + negated: false, + token: "T1".to_string(), + }, + IfCondition::EntityTag { + negated: false, + etag: "abc123".to_string(), + }, + ] + ); + } + + #[test] + fn parse_if_tagged_list_resource_ignored() { + // Tagged-list — the Resource prefix `` scopes the + // following Lists; our parser accepts but doesn't honour scoping. + let lists = parse_if_header(" ()"); + assert_eq!(lists.len(), 1); + assert_eq!( + lists[0], + vec![IfCondition::StateToken { + negated: false, + token: "T1".to_string(), + }] + ); + } + + #[test] + fn parse_if_complex_two_lists_token_and_etag() { + // The litmus fail_complex_cond_put shape. + let lists = + parse_if_header("( [\"etag1\"]) (Not [\"etag2\"])"); + assert_eq!(lists.len(), 2); + assert_eq!(lists[0].len(), 2); + assert_eq!(lists[1].len(), 2); + assert_eq!( + lists[1][0], + IfCondition::StateToken { + negated: true, + token: "DAV:no-lock".to_string(), + } + ); + } + + // Litmus `cond_put`: locked resource, `( [etag])`, matches + // both → header true, submitted the lock → proceed. + #[test] + fn evaluate_cond_put_success() { + let lists = parse_if_header("( [\"abc\"])"); + let (matches, submitted) = + evaluate_if_header(&lists, Some("opaquelocktoken:xyz"), Some("abc")); + assert!(matches); + assert!(submitted); + } + + // Litmus `fail_cond_put`: locked resource, bogus token, valid etag + // → List has token=FALSE AND etag=TRUE → FALSE. No matching token + // submitted → 423 (caller returns Locked). + #[test] + fn evaluate_fail_cond_put_bogus_token_valid_etag() { + let lists = parse_if_header("( [\"abc\"])"); + let (matches, submitted) = + evaluate_if_header(&lists, Some("opaquelocktoken:xyz"), Some("abc")); + assert!(!matches); + assert!(!submitted); + } + + // Litmus `fail_cond_put_unlocked`: unlocked resource, bogus token + // → List fails. No lock to submit → 412. + #[test] + fn evaluate_fail_cond_put_unlocked() { + let lists = parse_if_header("()"); + let (matches, submitted) = evaluate_if_header(&lists, None, None); + assert!(!matches); + assert!(!submitted); + } + + // Litmus `cond_put_with_not`: locked, `() (Not )` + // → List 1 true (token match). Header true. Submitted → proceed. + #[test] + fn evaluate_cond_put_with_not() { + let lists = parse_if_header("() (Not )"); + let (matches, submitted) = evaluate_if_header(&lists, Some("opaquelocktoken:xyz"), None); + assert!(matches); + assert!(submitted); + } + + // Litmus `cond_put_corrupt_token`: locked, `() (Not )` + // → List 2 (Not ) is TRUE, header matches. But no + // active-lock token submitted → 423 per §10.4.9. + #[test] + fn evaluate_cond_put_corrupt_token() { + let lists = parse_if_header("() (Not )"); + let (matches, submitted) = evaluate_if_header(&lists, Some("opaquelocktoken:xyz"), None); + assert!(matches); + assert!(!submitted); + } + + // Litmus `complex_cond_put`: locked, `( [etag]) (Not [etag])` + // with the CORRECT etag → List 1 true. Header true. Submitted → proceed. + #[test] + fn evaluate_complex_cond_put_success() { + let lists = + parse_if_header("( [\"abc\"]) (Not [\"abc\"])"); + let (matches, submitted) = + evaluate_if_header(&lists, Some("opaquelocktoken:xyz"), Some("abc")); + assert!(matches); + assert!(submitted); + } + + // Litmus `fail_complex_cond_put`: locked, `( [corrupt]) (Not [corrupt])` + // → both Lists AND to false (etag mismatch). Header FALSE. Token + // WAS submitted (in List 1) → 412 not 423. + #[test] + fn evaluate_fail_complex_cond_put() { + let lists = parse_if_header( + "( [\"corrupt\"]) (Not [\"corrupt\"])", + ); + let (matches, submitted) = + evaluate_if_header(&lists, Some("opaquelocktoken:xyz"), Some("abc")); + assert!(!matches); + assert!( + submitted, + "the valid token IS submitted, even though etag conditions fail" + ); + } + + #[test] + fn evaluate_etag_with_quotes_matches_raw() { + // The stored etag is raw (unquoted). The If: header quotes it. + // trim_matches should normalise both sides. + let lists = parse_if_header("([\"abc123-1234\"])"); + let (matches, _) = evaluate_if_header(&lists, None, Some("abc123-1234")); + assert!(matches); + } + + #[test] + fn parse_if_empty_returns_no_lists() { + assert_eq!(parse_if_header("").len(), 0); + } + + #[test] + fn evaluate_empty_lists_is_true() { + let (matches, submitted) = evaluate_if_header(&Vec::new(), None, None); + assert!(matches); + assert!(!submitted); + } + #[test] fn test_webdav_href_no_trailing_slash() { assert_eq!(