diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2170ff68..2edd9ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -369,6 +369,32 @@ jobs: path: tests/api/storage/ retention-days: 7 + litmus: + name: WebDAV RFC 4918 — litmus (59/59) + needs: build + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: oxicloud-release + path: target/release/ + + - name: Set execute bit on pre-built binary + run: chmod +x target/release/oxicloud + + - name: Install litmus and jq + run: sudo apt-get update -q && sudo apt-get install -y litmus jq + + - name: Run litmus WebDAV compliance tests + run: bash tests/webdav/run-litmus.sh + env: + BUILD_TARGET: release + LITMUS_TESTS: "basic copymove props locks" + front-test: name: Frontend end-to-end tests (via Playwright) # ensure that api tests are ok before diff --git a/migrations/20260825000000_webdav_dead_properties.sql b/migrations/20260825000000_webdav_dead_properties.sql new file mode 100644 index 00000000..9ba875f7 --- /dev/null +++ b/migrations/20260825000000_webdav_dead_properties.sql @@ -0,0 +1,20 @@ +-- WebDAV dead properties storage (RFC 4918 §9.2). +-- Stores arbitrary user-defined XML properties set via PROPPATCH. +-- Keyed by (resource_path, user_id, namespace, local_name) — the +-- same property on different resources or for different users is +-- a distinct row. + +CREATE TABLE IF NOT EXISTS storage.webdav_dead_properties ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + resource_path TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + namespace TEXT NOT NULL DEFAULT '', + local_name TEXT NOT NULL, + value TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (resource_path, user_id, namespace, local_name) +); + +CREATE INDEX IF NOT EXISTS idx_webdav_dead_properties_path_user + ON storage.webdav_dead_properties (resource_path, user_id); diff --git a/src/infrastructure/services/webdav_lock_service.rs b/src/infrastructure/services/webdav_lock_service.rs index 2b4ee389..686c0daf 100644 --- a/src/infrastructure/services/webdav_lock_service.rs +++ b/src/infrastructure/services/webdav_lock_service.rs @@ -106,15 +106,27 @@ impl WebDavLockStore { /// Attempt to acquire a lock on `path`. /// - /// Returns `Ok(LockEntry)` on success, or `Err(existing)` if the resource - /// is already exclusively locked by a different token. + /// Returns `Ok(LockEntry)` on success, or `Err(existing)` when: + /// - The existing lock is exclusive (blocks any new lock), or + /// - The new lock is exclusive and any lock already exists (RFC 4918 §7.8). #[allow(clippy::result_large_err)] pub fn acquire(&self, path: &str, info: LockInfo) -> Result { - // Check for existing conflicting lock - if let Some(existing) = self.by_path.get(path) - && existing.info.scope == LockScope::Exclusive - { - return Err(existing); + if let Some(existing) = self.by_path.get(path) { + // Exclusive existing lock → blocks everything. + // New exclusive lock → blocked by any existing lock (shared or exclusive). + if existing.info.scope == LockScope::Exclusive || info.scope == LockScope::Exclusive { + return Err(existing); + } + // Both shared: keep the first holder as the enforcement sentinel in + // `by_path` so releasing a secondary holder cannot clear the lock. + // Register the new token only in the reverse index so UNLOCK works. + let entry = LockEntry { + info, + path: path.to_owned(), + }; + self.by_token + .insert(entry.info.token.clone(), path.to_owned()); + return Ok(entry); } let entry = LockEntry { diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index d31d688d..d5603dc0 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -3,6 +3,7 @@ pub mod app_password_handler; pub mod auth_handler; pub mod batch_handler; pub mod caldav_handler; +pub mod calendar_rest_handler; pub mod carddav_handler; pub mod chunked_upload_handler; pub mod contacts_handler; diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 02543404..b2958f18 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -1062,20 +1062,67 @@ fn enforce_native_lock( if_header: Option<&str>, path: &str, ) -> Option> { - let entry = lock_store.get_by_path(path)?; - if let Some(h) = if_header - && extract_if_header_tokens(h) - .iter() - .any(|t| t == &entry.info.token) - { - return None; + // 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 + // covers all descendant members). + let entry = lock_store.get_by_path(path).or_else(|| { + let mut p = path; + loop { + let idx = p.rfind('/')?; + p = &p[..idx]; + if p.is_empty() { + return None; + } + if let Some(e) = lock_store.get_by_path(p) { + if e.info.depth.eq_ignore_ascii_case("infinity") { + return Some(e); + } + } + } + }); + + 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; + } + return Some( + Response::builder() + .status(StatusCode::LOCKED) + .body(Body::empty()) + .unwrap(), + ); } - Some( - Response::builder() - .status(StatusCode::LOCKED) - .body(Body::empty()) - .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 } /** diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 8276c4e9..7cc52917 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -592,6 +592,8 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { tracing::info!("Contacts REST API routes initialized"); } + + // NOTE: WebDAV routes are mounted at top-level (/webdav) in main.rs // for client compatibility, NOT under /api. diff --git a/tests/dav_compliance/rfc4918_proppatch.rs b/tests/dav_compliance/rfc4918_proppatch.rs new file mode 100644 index 00000000..5f62b7e2 --- /dev/null +++ b/tests/dav_compliance/rfc4918_proppatch.rs @@ -0,0 +1,343 @@ +//! RFC 4918 §9.2 PROPPATCH compliance — dead property storage and retrieval. + +use reqwest::Method; + +use super::harness::{get_server, unique_name}; + +fn propfind() -> Method { + Method::from_bytes(b"PROPFIND").unwrap() +} + +fn proppatch() -> Method { + Method::from_bytes(b"PROPPATCH").unwrap() +} + +/// PROPPATCH set a custom property → 207 with 200 propstat. +#[tokio::test] +async fn proppatch_set_returns_207() { + let srv = get_server(); + let path = format!("/webdav/{}", unique_name("pp_set")); + let (k, v) = srv.auth(); + + srv.client() + .put(srv.url(&path)) + .header(k, v.clone()) + .body("x") + .send() + .await + .unwrap(); + + let xml = r#" + + + + Alice + + +"#; + + let res = srv + .client() + .request(proppatch(), srv.url(&path)) + .header(k, v) + .header("Content-Type", "application/xml") + .body(xml) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 207, "PROPPATCH must return 207"); + let body = res.text().await.unwrap(); + assert!( + body.contains("200") || body.contains("HTTP/1.1 200"), + "PROPPATCH 207 must contain 200 propstat; body: {body}" + ); +} + +/// PROPPATCH set → PROPFIND retrieves the stored value. +#[tokio::test] +async fn proppatch_set_property_visible_in_propfind() { + let srv = get_server(); + let path = format!("/webdav/{}", unique_name("pp_roundtrip")); + let (k, v) = srv.auth(); + + srv.client() + .put(srv.url(&path)) + .header(k, v.clone()) + .body("data") + .send() + .await + .unwrap(); + + // Set dead property + let set_xml = r#" + + + + blue + + +"#; + + let pp_res = srv + .client() + .request(proppatch(), srv.url(&path)) + .header(k, v.clone()) + .header("Content-Type", "application/xml") + .body(set_xml) + .send() + .await + .unwrap(); + assert_eq!(pp_res.status(), 207, "PROPPATCH set must return 207"); + + // Retrieve via PROPFIND allprop + let pf_res = srv + .client() + .request(propfind(), srv.url(&path)) + .header(k, v) + .header("Depth", "0") + .send() + .await + .unwrap(); + assert_eq!(pf_res.status(), 207); + let body = pf_res.text().await.unwrap(); + assert!( + body.contains("color") || body.contains("blue"), + "PROPFIND allprop must include dead property set by PROPPATCH; body: {body}" + ); +} + +/// PROPPATCH remove → property absent from subsequent PROPFIND. +#[tokio::test] +async fn proppatch_remove_property_not_in_propfind() { + let srv = get_server(); + let path = format!("/webdav/{}", unique_name("pp_remove")); + let (k, v) = srv.auth(); + + srv.client() + .put(srv.url(&path)) + .header(k, v.clone()) + .body("data") + .send() + .await + .unwrap(); + + // First set + let set_xml = r#" + + removeme +"#; + srv.client() + .request(proppatch(), srv.url(&path)) + .header(k, v.clone()) + .header("Content-Type", "application/xml") + .body(set_xml) + .send() + .await + .unwrap(); + + // Then remove + let remove_xml = r#" + + +"#; + let rem_res = srv + .client() + .request(proppatch(), srv.url(&path)) + .header(k, v.clone()) + .header("Content-Type", "application/xml") + .body(remove_xml) + .send() + .await + .unwrap(); + assert_eq!(rem_res.status(), 207, "PROPPATCH remove must return 207"); + + // Verify gone — request the specific prop, expect 404 propstat + let pf_xml = r#" + + +"#; + let pf_res = srv + .client() + .request(propfind(), srv.url(&path)) + .header(k, v) + .header("Depth", "0") + .header("Content-Type", "application/xml") + .body(pf_xml) + .send() + .await + .unwrap(); + assert_eq!(pf_res.status(), 207); + let body = pf_res.text().await.unwrap(); + assert!( + body.contains("404"), + "Removed dead property must appear in 404 propstat; body: {body}" + ); +} + +/// PROPPATCH set + remove in same request → both applied atomically. +#[tokio::test] +async fn proppatch_set_and_remove_in_same_request() { + let srv = get_server(); + let path = format!("/webdav/{}", unique_name("pp_setrem")); + let (k, v) = srv.auth(); + + srv.client() + .put(srv.url(&path)) + .header(k, v.clone()) + .body("x") + .send() + .await + .unwrap(); + + // Pre-seed a property to remove + let seed_xml = r#" + + gone +"#; + srv.client() + .request(proppatch(), srv.url(&path)) + .header(k, v.clone()) + .header("Content-Type", "application/xml") + .body(seed_xml) + .send() + .await + .unwrap(); + + // Set new + remove old in one request + let xml = r#" + + here + +"#; + let res = srv + .client() + .request(proppatch(), srv.url(&path)) + .header(k, v) + .header("Content-Type", "application/xml") + .body(xml) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 207, "combined set+remove must return 207"); + let body = res.text().await.unwrap(); + // Both ops should succeed + assert!( + !body.contains("409") && !body.contains("403"), + "combined PROPPATCH must not fail; body: {body}" + ); +} + +/// PROPPATCH on non-existent resource → 404. +#[tokio::test] +async fn proppatch_nonexistent_resource_returns_404() { + let srv = get_server(); + let path = format!("/webdav/{}", unique_name("pp_ghost")); + let (k, v) = srv.auth(); + + let xml = r#" + + y +"#; + + let res = srv + .client() + .request(proppatch(), srv.url(&path)) + .header(k, v) + .header("Content-Type", "application/xml") + .body(xml) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + 404, + "PROPPATCH on non-existent resource must return 404" + ); +} + +/// PROPPATCH on collection (folder) → 207. +#[tokio::test] +async fn proppatch_on_collection_returns_207() { + let srv = get_server(); + let col = format!("/webdav/{}", unique_name("pp_col")); + let (k, v) = srv.auth(); + + srv.client() + .request(Method::from_bytes(b"MKCOL").unwrap(), srv.url(&col)) + .header(k, v.clone()) + .send() + .await + .unwrap(); + + let xml = r#" + + my folder +"#; + + let res = srv + .client() + .request(proppatch(), srv.url(&col)) + .header(k, v) + .header("Content-Type", "application/xml") + .body(xml) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 207, "PROPPATCH on collection must return 207"); +} + +/// PROPFIND specific dead property returns value in 200 propstat (not 404). +#[tokio::test] +async fn propfind_specific_dead_property_returns_200_propstat() { + let srv = get_server(); + let path = format!("/webdav/{}", unique_name("pp_specific")); + let (k, v) = srv.auth(); + + srv.client() + .put(srv.url(&path)) + .header(k, v.clone()) + .body("x") + .send() + .await + .unwrap(); + + // Set + let set_xml = r#" + + 5 +"#; + srv.client() + .request(proppatch(), srv.url(&path)) + .header(k, v.clone()) + .header("Content-Type", "application/xml") + .body(set_xml) + .send() + .await + .unwrap(); + + // PROPFIND for that exact property + let pf_xml = r#" + + +"#; + let pf_res = srv + .client() + .request(propfind(), srv.url(&path)) + .header(k, v) + .header("Depth", "0") + .header("Content-Type", "application/xml") + .body(pf_xml) + .send() + .await + .unwrap(); + assert_eq!(pf_res.status(), 207); + let body = pf_res.text().await.unwrap(); + assert!( + !body.contains("404"), + "Known dead property must not be in 404 propstat; body: {body}" + ); + assert!( + body.contains("rating") || body.contains("5"), + "Response must include the dead property value; body: {body}" + ); +} diff --git a/tests/webdav/run-litmus.sh b/tests/webdav/run-litmus.sh new file mode 100755 index 00000000..938da295 --- /dev/null +++ b/tests/webdav/run-litmus.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# WebDAV RFC 4918 compliance test using the litmus test suite. +# +# Usage (from repo root via justfile): +# just litmus-test +# +# Or directly (server + postgres must already be running): +# bash tests/webdav/run-litmus.sh +# +# Requires: litmus (apt install litmus), jq, curl +# litmus tests: basic copymove props locks + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +WEBDAV_DIR="$REPO_ROOT/tests/webdav" + +source "$WEBDAV_DIR/test.env" + +SERVER_PORT="${base_url##*:}" + +log() { echo "[litmus] $*"; } +die() { echo "[litmus] ERROR: $*" >&2; exit 1; } + +# ── Dependency checks ────────────────────────────────────────────────────────── + +if ! command -v litmus >/dev/null 2>&1; then + die "litmus not found. Install with: sudo apt install litmus" +fi +if ! command -v jq >/dev/null 2>&1; then + die "jq not found. Install with: sudo apt install jq" +fi + +# ── Teardown ─────────────────────────────────────────────────────────────────── + +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + bash "$COMMON/stop-db.sh" +} + +trap cleanup EXIT + +# ── 1. Start postgres ────────────────────────────────────────────────────────── + +bash "$COMMON/spawn-db.sh" + +# ── 2. Start OxiCloud ───────────────────────────────────────────────────────── + +set -a +source "$COMMON/server.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/webdav/storage-litmus" +set +a + +rm -rf "$OXICLOUD_STORAGE_PATH" +mkdir -p "$OXICLOUD_STORAGE_PATH" + +BUILD_TARGET="${BUILD_TARGET:-debug}" +OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" + +if [[ -x "$OXICLOUD_BIN" ]]; then + log "Starting pre-built OxiCloud ($BUILD_TARGET) on port $SERVER_PORT..." + "$OXICLOUD_BIN" --config "$COMMON/server.env" & +else + log "Building and starting OxiCloud on port $SERVER_PORT..." + cd "$REPO_ROOT" + cargo build 2>&1 + "$REPO_ROOT/target/debug/oxicloud" --config "$COMMON/server.env" & +fi +SERVER_PID=$! + +log "Waiting for server at $base_url..." +deadline=$(( $(date +%s) + 60 )) +until curl -sf "$base_url/ready" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Server did not become ready within 60s" + sleep 1 +done +log "Server ready." + +# ── 3. Bootstrap admin + app password ──────────────────────────────────────── + +SETUP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$username\",\"email\":\"$email\",\"password\":\"$password\"}" \ + "$base_url/api/setup") +case "$SETUP_STATUS" in + 201) log "Admin account created." ;; + 403) log "Admin account already exists." ;; + *) die "Unexpected /api/setup status: $SETUP_STATUS" ;; +esac + +LOGIN_RESP=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$username\",\"password\":\"$password\"}" \ + "$base_url/api/auth/login") +JWT=$(jq -r '.access_token' <<<"$LOGIN_RESP") +[[ -z "$JWT" || "$JWT" == "null" ]] && die "Login failed: $LOGIN_RESP" +log "Logged in as $username." + +APP_PW_RESP=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $JWT" \ + -d '{"label":"litmus-test"}' \ + "$base_url/api/auth/app-passwords") +APP_PASSWORD=$(jq -r '.password' <<<"$APP_PW_RESP") +[[ -z "$APP_PASSWORD" || "$APP_PASSWORD" == "null" ]] && die "App password creation failed: $APP_PW_RESP" +log "App password created." + +# ── 4. Run litmus ───────────────────────────────────────────────────────────── + +LITMUS_TESTS="${LITMUS_TESTS:-basic copymove props locks}" +WEBDAV_URL="$base_url/webdav/" + +log "Running litmus $LITMUS_TESTS against $WEBDAV_URL" +TESTS="$LITMUS_TESTS" litmus "$WEBDAV_URL" "$username" "$APP_PASSWORD" + +log "litmus passed."