diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29ed76d3..ed076ede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -369,6 +369,15 @@ jobs: env: BUILD_TARGET: release + # WebDAV URL-scheme variant: `OXICLOUD_WEBDAV_DRIVE_PATH=""` + # (drive listing at `/webdav/`, no `@drive` sigil). Runs a + # separately-configured server on its own port so the default + # WebDAV suite above stays on the `"@drive"` back-compat config. + - name: Run WebDAV drive-root variant tests + run: bash tests/webdav-drive-root/run.sh + env: + BUILD_TARGET: release + # OIDC integration: drives the SPA's SSO flow end-to-end against # the fake IdP (auto-approve login + consent, real PKCE/JWT # round-trip) and asserts the d1bbe8ba contract — OIDC callback diff --git a/docs/config/env.md b/docs/config/env.md index 316bd0a1..cabc50eb 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -70,6 +70,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_ENABLE_MUSIC` | `true` | Music playlists and audio metadata | | `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` | | `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS` | `false` | Expose `POST /api/admin/internal/trigger-sweep` and `POST /api/admin/internal/trigger-gc` — test-only synchronous triggers for the storage-usage reconciliation sweep and blob garbage collector. Used by the API test suite to assert post-delete quota convergence without waiting out the periodic ticker. Leave **off** in production: the routes return 404 even to an admin token when disabled. | +| `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive//…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav//…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. | ## Storage Backend diff --git a/docs/plan/drive.md b/docs/plan/drive.md index ef8fcc1f..cf01db69 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -761,15 +761,24 @@ accommodates them without schema migration) #### Native WebDAV (`/webdav/...`) -| URL | Resolves to | -|---|---| -| `/webdav/` | Caller's default personal drive root + `` (back-compat with today's behaviour) | -| `/webdav/@drive//` | Specific drive root + `` | +**SHIPPED 2026-07-06.** Config-driven via env +`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` (`FeaturesConfig::webdav_drive_listing_prefix`; +default `"@drive"`, sanitized by trimming leading/trailing `/`). +Three deployment shapes: -Today's `/webdav/` handler implicitly looks up the caller's -home folder and prepends it. Post-drives, the same handler looks up -the caller's personal drive and resolves paths inside it. **Zero -breakage** for existing native WebDAV clients. +| `WEBDAV_DRIVE_LISTING_PREFIX` | URL | Resolves to | +|---|---|---| +| `@drive` (default) | `/webdav/…` | caller's default personal drive (back-compat) | +| `@drive` | `/webdav/@drive/` | drive listing | +| `@drive` | `/webdav/@drive//…` | specific drive | +| `""` (empty) | `/webdav/` | drive listing | +| `""` | `/webdav//…` | specific drive | +| any other | same shape as `@drive`, segment substituted | | + +`` is a drive UUID **or** the drive's display name (matched +against `storage.folders.name` of the drive root). Only drives the +caller has Read on via `role_grants` resolve; unknown selector and +permission denial both return 404 (anti-enumeration). **Why the `@drive` sigil and NOT `/webdav/drives//...`** (earlier draft) or top-level `/drives//...` (also @@ -777,32 +786,47 @@ considered): `@` is the established structural-routing sigil (GitHub `@user/repo`, npm `@scope/pkg`, LDAP `@domain`) — it reads as "this is not user content, this is a routing token." Realistic collision risk drops to near-zero: nobody creates a -top-level folder named exactly `@drive` by accident, and the -defensive layer collapses to a single one-liner in MKCOL / PUT / -REST create paths that refuses that literal name at any drive -root. Compared to top-level `/drives//...`, the `@drive` -shape keeps **one URL root for everything WebDAV** — single -`` block in reverse-proxy configs, single mental model -for sysadmins, single dispatcher in `webdav_routes()`. +top-level folder named exactly `@drive` by accident. Keeps **one +URL root for everything WebDAV** — single `` block in +reverse-proxy configs, single mental model for sysadmins, single +dispatcher in `webdav_routes()`. Making the segment +config-tunable per deployment lets operators pick a different +sigil (`drives`) or drop it entirely (`""` = drive-listing at +root) without a code change. -**Implementation notes:** -- Route parser accepts both `/webdav/@drive//...` and the - URL-encoded form `/webdav/%40drive//...` — WebDAV clients - percent-encode `@` inconsistently. -- One-liner guard in upload paths refuses creation of a folder - literally named `@drive` at any drive root (case-sensitive). -- `webdav_href()` (today at `webdav_handler.rs:94`) becomes - drive-context-aware: responses for a request under - `/webdav/@drive//...` must reference back to - `/webdav/@drive//...`, otherwise the client follows the - `` and lands on the back-compat surface (wrong drive). +**Implementation:** `resolve_webdav_scope` in +`src/interfaces/api/handlers/webdav_handler.rs`. Selector accepts +UUIDs and display names; UUID form is tried first. Legacy +tolerance in the default-drive branch: bookmarks that already +carried the drive-root name as their first segment +(`/webdav/Personal/foo` under a Personal-default user) are +passed through instead of double-prepended. -The `drives` path segment is **reserved**: a folder literally named -`drives` cannot exist at the top level of any drive. Migration -pre-check refuses to start if existing data violates this — operator -must rename before upgrading. (Conservative estimate: zero existing -folders are named exactly `drives`. The migration script reports any -collisions for manual fix-up.) +**Hurl coverage:** +- `tests/api/webdav_drive_root.hurl` — default `@drive` config +- `tests/webdav-drive-root/drive_root_empty_config.hurl` — empty + config (separately-configured server; runs under + `tests/webdav-drive-root/run.sh`, wired into `just api-test` + and CI's `api-test` job) + +**Href construction — verified drive-aware:** `webdav_href()` +prints `/webdav/`, but the `` input is `client_path` +extracted from `req.uri()` (the URL segment after `/webdav/`), not +the scope-resolved db_path. So a request to +`/webdav/@drive//folder/` renders children as +`/webdav/@drive//folder//` — the `@drive//` +prefix is preserved on every hop. `client_path` is threaded into +`base_href` at `handle_propfind` and passed through +`build_streaming_propfind_response` unchanged. + +**Deferred (not blocking):** +- One-liner guard refusing folder creation named literally + `@drive` at drive root (defensive against future collisions — + today an unknown `@drive` folder at drive root is unreachable + via WebDAV under the default config, so it's low priority). +- Cross-drive MOVE / COPY currently 403 — same-drive only. + Cross-drive copy has REST-side support; WebDAV MOVE/COPY + could route through it once permission mapping is designed. #### NextCloud-compat WebDAV (`/remote.php/dav/...`) diff --git a/example.env b/example.env index c500b869..b814ae7d 100644 --- a/example.env +++ b/example.env @@ -85,6 +85,30 @@ OXICLOUD_SERVER_HOST=127.0.0.1 # to an admin token. Default: false. #OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=false +# Native WebDAV URL segment that returns the drive listing. Sanitized +# by trimming leading/trailing `/` so `/@drive/`, `@drive`, and +# `@drive/` are equivalent. Three deployment modes: +# +# * Default `@drive` — back-compat with pre-multi-drive clients. +# /webdav/… → caller's default personal drive +# /webdav/@drive/ → drive listing (per-drive virtual +# folders) +# /webdav/@drive//… → specific drive by UUID or its +# display name +# +# * Empty `""` — no default-drive shortcut; `/webdav/` IS the +# drive listing. Clients must always name the drive. +# /webdav/ → drive listing +# /webdav//… → specific drive +# +# * Any other string (e.g. `drives`) — same shape as `@drive` but +# with your chosen segment substituted. +# +# Selector `` is a drive UUID or the drive's display name. Only +# drives the caller has Read on via role_grants resolve; unknown +# selector and permission denial both return 404 (anti-enumeration). +#OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=@drive + # How often (milliseconds) the background job drains storage.tree_etag_dirty # and bumps folder tree ETags (default: 500). Write paths only enqueue bump # requests — this is the upper bound on how stale an ancestor folder's ETag diff --git a/justfile b/justfile index 35e78e6d..f6db94cd 100644 --- a/justfile +++ b/justfile @@ -159,23 +159,34 @@ front-design: # Hurl-driven functional tests (starts postgres + server, tears down after). # -# Three runners — each isolated, brings up its own sidecars + server config: -# * tests/api/run.sh — REST API surface, default server.env -# * tests/webdav/run.sh — native WebDAV + NextCloud DAV, default server.env -# * tests/oidc/run.sh — OIDC SSO end-to-end against a fake IdP -# (tests/oidc/fake_idp, a Node panva/oidc-provider -# wrapper); server launched with -# --config server-with-oidc.env so the api and -# webdav suites stay on the OIDC-off config. +# Four runners — each isolated, brings up its own sidecars + server config: +# * tests/api/run.sh — REST API surface, default server.env +# * tests/webdav/run.sh — native WebDAV + NextCloud DAV, default server.env +# * tests/webdav-drive-root/run.sh — WebDAV `OXICLOUD_WEBDAV_DRIVE_PATH=""` +# variant (drive listing served at +# `/webdav/` instead of `/webdav/@drive/`). +# Server launched with +# --config server-webdav-drive-root.env +# so the default runners stay on the +# `"@drive"` config. +# * tests/oidc/run.sh — OIDC SSO end-to-end against a fake IdP +# (tests/oidc/fake_idp, a Node +# panva/oidc-provider wrapper); server +# launched with +# --config server-with-oidc.env so the +# api and webdav suites stay on the +# OIDC-off config. # # Same chain runs in CI under the `api-test` job in # .github/workflows/ci.yml; keep the order in sync so a local pass means # CI passes. api-test: #!/usr/bin/env bash + set -x set -euo pipefail ./tests/api/run.sh ./tests/webdav/run.sh + ./tests/webdav-drive-root/run.sh ./tests/oidc/run.sh if which litmus >/dev/null 2>/dev/null then diff --git a/src/common/config.rs b/src/common/config.rs index 50869c3b..a5fc1f79 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -915,6 +915,22 @@ pub struct FeaturesConfig { /// deployments don't want them reachable. Env: /// `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS`. pub enable_admin_internal_endpoints: bool, + /// Native WebDAV path segment that lists the caller's drives. + /// + /// * Default `"@drive"` — bare `/webdav/` addresses the caller's + /// default personal drive (back-compat). Drive listing lives at + /// `/webdav/@drive/`; explicit drive at + /// `/webdav/@drive//…`. + /// * `""` (empty) — no default-drive shortcut. Bare `/webdav/` + /// returns the drive listing; explicit drive at + /// `/webdav//…`. Operators who don't want a "default + /// drive" concept exposed via WebDAV pick this. + /// * Any other string (e.g. `"drives"`) — same shape as the default, + /// just with that path segment. Loaded via `trim_matches('/')` + /// so operators can safely pass `"/drives/"`. + /// + /// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`. + pub webdav_drive_listing_prefix: String, } impl Default for FeaturesConfig { @@ -934,6 +950,10 @@ impl Default for FeaturesConfig { // deployments do NOT need this; the periodic ticker handles // reconciliation transparently. enable_admin_internal_endpoints: false, + // Back-compat with pre-multi-drive clients — bare `/webdav/` + // maps to the caller's default drive; drive listing is + // reachable at `/webdav/@drive/`. + webdav_drive_listing_prefix: "@drive".to_string(), } } } @@ -1505,6 +1525,15 @@ impl AppConfig { config.features.enable_admin_internal_endpoints = val; } + // Native WebDAV drive-picker path segment. Sanitised by + // stripping leading/trailing slashes so operators can pass + // `/drives/` or `drives` interchangeably; empty string means + // "no default-drive shortcut, `/webdav/` IS the drive listing". + // See `FeaturesConfig::webdav_drive_listing_prefix`. + if let Ok(raw) = env::var("OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX") { + config.features.webdav_drive_listing_prefix = raw.trim_matches('/').to_string(); + } + if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::()) && let Ok(val) = enable_faces { diff --git a/src/infrastructure/services/webdav_lock_service.rs b/src/infrastructure/services/webdav_lock_service.rs index 686c0daf..919b6bfc 100644 --- a/src/infrastructure/services/webdav_lock_service.rs +++ b/src/infrastructure/services/webdav_lock_service.rs @@ -32,6 +32,14 @@ const MAX_LOCK_TIMEOUT_SECS: u64 = 86_400; // 24 hours pub struct LockEntry { pub info: LockInfo, pub path: String, + /// The user who acquired the lock. `None` for entries seeded by + /// unit tests or refresh paths that don't carry a caller (the + /// refresh flow rebuilds from the existing entry without a new + /// caller context, so we preserve whatever was there). RFC 4918 + /// §9.11's "MUST be requested by the owner" rule for UNLOCK is + /// enforced by comparing this against the caller in + /// `handle_unlock`. + pub caller_user_id: Option, } /// Per-entry expiration policy for the `by_path` cache. @@ -110,7 +118,12 @@ impl WebDavLockStore { /// - 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 { + pub fn acquire( + &self, + path: &str, + info: LockInfo, + caller_user_id: Option, + ) -> Result { 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). @@ -123,6 +136,7 @@ impl WebDavLockStore { let entry = LockEntry { info, path: path.to_owned(), + caller_user_id, }; self.by_token .insert(entry.info.token.clone(), path.to_owned()); @@ -132,6 +146,7 @@ impl WebDavLockStore { let entry = LockEntry { info, path: path.to_owned(), + caller_user_id, }; // `LockExpiry` derives the TTL from `entry.info.timeout` on insert — @@ -254,6 +269,7 @@ mod tests { LockEntry { info: lock_info(token, timeout, LockScope::Exclusive), path: "/file.txt".to_owned(), + caller_user_id: None, } } diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 27286d73..6761efce 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -222,45 +222,170 @@ async fn handle_webdav_methods( handle_webdav_dispatch(state, req, path).await } -/// If `path` doesn't already start with the user's home folder name, prepend -/// the home folder path so downstream services can find the resource in the DB. -/// Returns `None` when the path already includes the prefix or resolution fails. -async fn resolve_webdav_path(state: &Arc, user_id: Uuid, path: &str) -> Option { - let folder_service = &state.applications.folder_service; - let home_folders = folder_service - .list_folders_with_perms(None, user_id) - .await - .ok()?; - let home = home_folders.first()?; - - if path.starts_with(&home.name) { - None // Already prefixed - } else { - Some(format!("{}/{}", home.path, path)) - } +/// Native WebDAV URL scheme (drive.md §9): +/// +/// The exact wire shape depends on +/// `FeaturesConfig::webdav_drive_listing_prefix` (env +/// `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`, default `"@drive"`): +/// +/// | Config | URL | Target | +/// |---|---|---| +/// | `"@drive"` | `/webdav/…` | default drive (back-compat) | +/// | `"@drive"` | `/webdav/@drive/` | drive listing | +/// | `"@drive"` | `/webdav/@drive//…` | explicit drive | +/// | `""` | `/webdav/` | drive listing | +/// | `""` | `/webdav//…` | explicit drive | +/// | `"drives"` | `/webdav/…` | default drive | +/// | `"drives"` | `/webdav/drives//…` | explicit drive | +/// +/// `` is a drive UUID **or** the drive's display name (matched +/// against `storage.folders.name` of the drive root). Only drives the +/// caller has Read on via `role_grants` resolve. +/// +/// Legacy tolerance for the default-drive branch: bookmarks that +/// already contain the drive-root name as their first segment +/// (`/webdav/Personal/foo` under a Personal-default user) are passed +/// through instead of double-prepended. +enum WebdavTarget { + /// Render the synthetic drive-listing pseudo-root. Only PROPFIND + /// treats this as a real target; other verbs 405. + ListDrives, + /// Descend into a concrete drive. + Scope(DriveScope), } -/// Native WebDAV protocol entry: resolve the caller's default drive -/// once per handler so every downstream path-based lookup -/// (`get_folder_by_path`, `get_file_by_path`, `update_file_streaming`) -/// can pass the same `drive_id` scope. -/// -/// Post-D0 `storage.{folders,files}.path` repeats across drives — the -/// scope is mandatory. Native WebDAV today lives in a single-drive -/// surface (one default drive per user), so the lookup is unambiguous. -/// Multi-drive support via path segments (`/webdav/drives//…`) -/// is tracked separately and will derive `drive_id` directly from the -/// URL instead of going through `find_default_for_user`. -async fn resolve_drive_id_for_native_webdav( +struct DriveScope { + drive_id: Uuid, + /// Path in `storage.folders.path` format (drive-root name is the + /// leading segment; that prefix is stored per D7). + db_path: String, +} + +async fn resolve_webdav_scope( state: &Arc, user_id: Uuid, -) -> Result { - state + url_path: &str, +) -> Result { + let drive_prefix = state + .core + .config + .features + .webdav_drive_listing_prefix + .as_str(); + let normalized = url_path.trim_matches('/'); + + // Mode A: empty prefix. `/webdav/` IS the drive listing. + if drive_prefix.is_empty() { + if normalized.is_empty() { + return Ok(WebdavTarget::ListDrives); + } + let (selector, subpath) = normalized.split_once('/').unwrap_or((normalized, "")); + let drive = lookup_drive_selector(state, user_id, selector).await?; + return Ok(WebdavTarget::Scope(DriveScope { + drive_id: drive.drive.id, + db_path: join_drive_path(&drive.root_folder_name, subpath), + })); + } + + // Mode B: non-empty prefix (default `@drive`). Bare `/webdav/` is + // the caller's default drive; drive listing lives at + // `/webdav//`. + let listing_marker = drive_prefix; + if normalized == listing_marker { + return Ok(WebdavTarget::ListDrives); + } + let with_slash = format!("{}/", listing_marker); + if let Some(after_prefix) = normalized.strip_prefix(&with_slash) { + if after_prefix.is_empty() { + return Ok(WebdavTarget::ListDrives); + } + let (selector, subpath) = after_prefix.split_once('/').unwrap_or((after_prefix, "")); + let drive = lookup_drive_selector(state, user_id, selector).await?; + return Ok(WebdavTarget::Scope(DriveScope { + drive_id: drive.drive.id, + db_path: join_drive_path(&drive.root_folder_name, subpath), + })); + } + + // Default-drive back-compat. + let default = state .drive_repo .find_default_for_user(user_id) .await - .map(|d| d.drive.id) - .map_err(|e| AppError::internal_error(format!("Failed to resolve default drive: {:?}", e))) + .map_err(|e| { + AppError::internal_error(format!("Failed to resolve default drive: {:?}", e)) + })?; + let root_name = default.root_folder_name.as_str(); + let db_path = if normalized.is_empty() { + root_name.to_string() + } else if normalized == root_name || normalized.starts_with(&format!("{}/", root_name)) { + // Pre-refactor bookmark already carried the drive-root prefix. + normalized.to_string() + } else { + join_drive_path(root_name, normalized) + }; + Ok(WebdavTarget::Scope(DriveScope { + drive_id: default.drive.id, + db_path, + })) +} + +/// Convenience: unwrap the common Scope branch or map ListDrives to a +/// 405-shape error. Used by every write verb (PUT/DELETE/MOVE/COPY/…) +/// that can't sensibly operate on the drive-listing pseudo-root. +async fn resolve_webdav_scope_or_405( + state: &Arc, + user_id: Uuid, + url_path: &str, +) -> Result { + match resolve_webdav_scope(state, user_id, url_path).await? { + WebdavTarget::Scope(s) => Ok(s), + WebdavTarget::ListDrives => Err(AppError::method_not_allowed( + "Method not supported on the drive-listing pseudo-root", + )), + } +} + +fn join_drive_path(root_name: &str, subpath: &str) -> String { + let subpath = subpath.trim_start_matches('/').trim_end_matches('/'); + if subpath.is_empty() { + root_name.to_string() + } else { + format!("{}/{}", root_name, subpath) + } +} + +/// Resolve `@drive/`: try the selector as a UUID first, then +/// fall back to matching the drive-root folder's display name. Only +/// drives the caller has Read access to via `role_grants` are +/// considered — an unknown selector and a permission denial return the +/// same `NotFound` to preserve anti-enumeration. +async fn lookup_drive_selector( + state: &Arc, + user_id: Uuid, + selector: &str, +) -> Result { + let selector_decoded = percent_decode_str(selector).decode_utf8_lossy(); + let uuid_opt = Uuid::parse_str(selector_decoded.as_ref()).ok(); + let visible = state + .drive_repo + .list_readable_by(user_id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to list drives: {:?}", e)))?; + for d in visible { + if let Some(uuid) = uuid_opt + && d.drive.id == uuid + { + return Ok(d); + } + if d.root_folder_name == selector_decoded.as_ref() { + return Ok(d); + } + } + Err(AppError::not_found(format!( + "Drive '{}' not found", + selector_decoded + ))) } async fn handle_webdav_dispatch( @@ -270,21 +395,9 @@ async fn handle_webdav_dispatch( ) -> Result, AppError> { let method = req.method().clone(); - // Translate WebDAV path → DB path by prepending user's home folder - // prefix when the path doesn't already include it. - // Extract user_id before any async call to keep the future Send. - let path = if !path.is_empty() && method.as_str() != "OPTIONS" { - let user_id = req.extensions().get::>().map(|u| u.id); - if let Some(uid) = user_id { - resolve_webdav_path(&state, uid, &path) - .await - .unwrap_or(path) - } else { - path - } - } else { - path - }; + // Path is left as the raw URL path (post-`/webdav/`). Every handler + // that touches storage calls `resolve_webdav_scope` to translate the + // URL → (drive_id, db_path). match method.as_str() { "OPTIONS" => handle_options(path).await, @@ -419,46 +532,46 @@ async fn handle_propfind( }; // ── 5. Determine target resource ───────────────────────────── - if path.is_empty() || path == "/" { - // Root folder - let root_folder = FolderDto { - id: "root".to_string(), - etag: "root".to_string(), - name: "".to_string(), - path: "".to_string(), - parent_id: None, - // Synthetic root folder for PROPFIND on `/`; not an - // actual DB row, so drive_id has no meaningful value. - drive_id: Uuid::nil(), - created_at: Utc::now().timestamp() as u64, - modified_at: Utc::now().timestamp() as u64, - is_root: true, - icon_class: Arc::from("fas fa-folder"), - icon_special_class: Arc::from("folder-icon"), - category: Arc::from("Folder"), - // §14 provenance not applicable to the synthetic root. - created_by: None, - updated_by: None, - }; - - return build_streaming_propfind_response( - root_folder, - None, // folder_id = None → root children - &depth_owned, - &base_href, - propfind_request, - folder_service, - file_retrieval_service, - user.id, - state.webdav_dead_props.clone(), - ) - .await; - } - - // `drive_id` is mandatory post-D0 for path-based lookups. Native - // WebDAV resolves it once from the caller's default drive and - // reuses it for the resolver / fallback probes below. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + // + // `resolve_webdav_scope` handles the URL → scope translation using + // `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`. It can return either a concrete + // drive scope or the synthetic drive-listing pseudo-root. Only + // PROPFIND treats `ListDrives` as a valid target — other verbs use + // `resolve_webdav_scope_or_405` which errors on that branch. + let (drive_id, path) = match resolve_webdav_scope(&state, user.id, &path).await? { + WebdavTarget::ListDrives => { + let root_folder = FolderDto { + id: "root".to_string(), + etag: "root".to_string(), + name: "".to_string(), + path: "".to_string(), + parent_id: None, + // Synthetic root — not a real DB row. + drive_id: Uuid::nil(), + created_at: Utc::now().timestamp() as u64, + modified_at: Utc::now().timestamp() as u64, + is_root: true, + icon_class: Arc::from("fas fa-folder"), + icon_special_class: Arc::from("folder-icon"), + category: Arc::from("Folder"), + created_by: None, + updated_by: None, + }; + return build_streaming_propfind_response( + root_folder, + None, // folder_id = None → root children (drive-root folders) + &depth_owned, + &base_href, + propfind_request, + folder_service, + file_retrieval_service, + user.id, + state.webdav_dead_props.clone(), + ) + .await; + } + WebdavTarget::Scope(scope) => (scope.drive_id, scope.db_path), + }; // Single-query path resolution: folder OR file in one DB round-trip. // @@ -772,6 +885,13 @@ async fn handle_proppatch( let user = extract_user(&req)?; // Client-facing path for href construction (without home folder prefix). let client_path = extract_webdav_path(req.uri()); + // Scope the URL → (drive_id, db_path). The synthetic drive-listing + // pseudo-root has no DB row to anchor dead properties on; treat + // it as an empty target and reject the PROPPATCH itself below. + let (drive_id, path) = match resolve_webdav_scope(&state, user.id, &path).await? { + WebdavTarget::ListDrives => (Uuid::nil(), String::new()), + WebdavTarget::Scope(scope) => (scope.drive_id, scope.db_path), + }; // Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties, // so a lock on the target must release them via `If:`. Captured @@ -813,7 +933,7 @@ async fn handle_proppatch( // PROPPATCH itself below so we don't fabricate a target. (None, true) } else { - match resolve_or_legacy(&state, &path, user.id).await { + match resolve_or_legacy(&state, &path, drive_id).await { Some(ResolvedResource::Folder(folder)) => { let id = Uuid::parse_str(&folder.id).map_err(|e| { AppError::internal_error(format!("Folder id is not a UUID: {e}")) @@ -831,6 +951,21 @@ async fn handle_proppatch( let resource_ref = resource_ref .ok_or_else(|| AppError::forbidden("PROPPATCH on the WebDAV root is not supported"))?; + // AuthZ: PROPPATCH writes dead properties on the target — that's + // a mutation, requires `Update`. Without this check any caller who + // can Read (e.g. a Viewer-role grant) could persist dead-prop rows + // on someone else's file. Anti-enum-preserving: `require` maps + // denial to `NotFound`, matching the anonymous-not-found response + // above. + let resource = match resource_ref { + ResourceRef::Folder(id) => Resource::Folder(id), + ResourceRef::File(id) => Resource::File(id), + }; + state + .authorization + .require(Subject::User(user.id), Permission::Update, resource) + .await?; + // Read request body (XML — bounded to 1 MB) let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY) .await @@ -909,7 +1044,9 @@ async fn handle_get( // `drive_id` is the path-lookup scope post-D0 (paths repeat across // drives), derived once from the caller's default drive and reused // by both the resolver + legacy fallback. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; // Resolve file — drive-scoped when PathResolver is available. // Post-D7 both branches enforce `Read` on the resolved file @@ -1034,7 +1171,9 @@ async fn handle_head( // `drive_id` is the path-lookup scope post-D0 — derive once and // reuse across the resolver + fallback branches below. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; // Single-query path resolution (drive-scoped). Both branches // enforce `Read` on the resolved resource before emitting the @@ -1165,20 +1304,12 @@ async fn handle_head( async fn resolve_or_legacy( state: &Arc, path: &str, - user_id: Uuid, + drive_id: Uuid, ) -> Option { - // Path-lookup scope post-D0 — derive the caller's default drive - // once and reuse across both probes. `find_default_for_user` - // returning Err (e.g. external user, or boot before the lifecycle - // hook fired) means no resolution is possible: return None. - let drive_id = state - .drive_repo - .find_default_for_user(user_id) - .await - .ok()? - .drive - .id; - + // `drive_id` is now passed in by the caller (already computed by + // `resolve_webdav_scope`) so the fallback probes stay consistent + // with the primary resolver — cross-drive URLs no longer silently + // fall back to the caller's default drive. if let Some(resolver) = &state.path_resolver && let Ok(r) = resolver.resolve_path_in_drive(path, drive_id).await { @@ -1594,7 +1725,9 @@ async fn handle_put( // `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. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; // ── Existence check ─────────────────────────────────────────────── // Resolves to: File(existing), Folder(wrong), or Err(new file). @@ -1782,10 +1915,11 @@ async fn handle_put( .body(Body::empty()) .unwrap()) } - Err(e) => Err(AppError::internal_error(format!( - "Failed to put file: {}", - e - ))), + // Propagate DomainError kinds — NotFound (authz denial via + // `require_target_folder_perm`), Conflict (missing parent) etc. + // Wrapping everything as InternalError swallowed 404s from the + // service's own AuthZ, surfacing them to callers as 500. + Err(e) => Err(AppError::from(e)), } } @@ -1807,9 +1941,13 @@ async fn handle_mkcol( let user = extract_user(&req)?; let folder_service = &state.applications.folder_service; - if path.is_empty() || path == "/" { - return Err(AppError::conflict("Root folder already exists")); - } + // Bare `/webdav/` handling: routed through `resolve_webdav_scope_or_405` + // below. In the empty-drive-path config that resolves to the + // drive-listing pseudo-root (405 method-not-allowed); in the + // default `@drive` config it resolves to the default drive's root + // folder (which already exists — the existence probe at + // `exists_in_drive` further down returns 405 per RFC 4918 §9.3.1). + // Both configs end at 405 without a special-case. // Extract content-type before consuming the body. let req_content_type = req @@ -1848,7 +1986,9 @@ async fn handle_mkcol( // This handler only creates a single collection (the last path segment). // It does NOT auto-create intermediate ancestors ("mkdir -p" semantics // violate the RFC and were causing the test failures). - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); if segments.is_empty() { @@ -1977,6 +2117,22 @@ async fn handle_delete( ) -> Result, AppError> { let user = extract_user(&req)?; + // Refuse DELETE on the pseudo-root before any scope work — bare + // `/webdav/` (empty-config drive listing OR classic-config default + // drive root) can't be deleted from the WebDAV surface. + if path.is_empty() || path == "/" { + return Err(AppError::forbidden("Cannot delete root folder")); + } + + // Scope resolution BEFORE the lock guard so `enforce_native_lock` + // keys on the same DB path that `handle_lock` used when it + // registered the lock. Doing it in the reverse order (as before + // the drive-scope refactor) silently defeated every LOCK because + // the lock-store key mismatch made every DELETE look unlocked. + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; + // Active-lock guard (RFC 4918 §9.10.4). let if_header_owned = req .headers() @@ -1997,17 +2153,12 @@ async fn handle_delete( let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; - // Check if path is empty (root folder) - if path.is_empty() || path == "/" { - return Err(AppError::forbidden("Cannot delete root folder")); - } - // Resolve via optimized resolver, falling back to the legacy // double-query lookup (the one GET uses). Necessary because the // optimized resolver and the read repositories disagree on path // shape for some files; see `resolve_or_legacy` docs. let _ = file_retrieval_service; // present for legacy fallback if needed elsewhere - match resolve_or_legacy(&state, &path, user.id).await { + match resolve_or_legacy(&state, &path, drive_id).await { Some(ResolvedResource::Folder(folder)) => { folder_service .delete_folder_with_perms(&folder.id, user.id) @@ -2062,17 +2213,6 @@ async fn handle_move( .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - // Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move - // removes the source resource, which counts as modifying it. - if let Some(resp) = enforce_native_lock( - &state.webdav_lock_store, - if_header_owned.as_deref(), - &source_path, - None, - ) { - return Ok(resp); - } - // Get destination from Destination header let destination = req .headers() @@ -2101,22 +2241,41 @@ async fn handle_move( // SECURITY: reject path-traversal in destination reject_path_traversal(&destination_path)?; - // Normalize destination through the SAME path-prefixing that - // `resolve_webdav_path` applied to `source_path` during dispatch. - // Without this, comparing source_parent_path (already prefixed with - // the user's home folder name) against dest_parent_path (raw from - // the URL, no prefix) always reports "different parent" — even for a - // pure rename at the same level — and breaks the move/rename branch - // selection below. - let destination_path = resolve_webdav_path(&state, user.id, &destination_path) - .await - .unwrap_or(destination_path); + // Resolve BOTH source and destination scope. Cross-drive MOVE is + // permitted: the underlying service methods + // (`move_folder_with_perms` / `move_file_with_perms`) support it + // natively — they enforce the D5 `forbid_cross_drive_move` policy + // per drive and emit a D6 `resource.moved_between_drives` audit + // line when the move crosses a boundary. Downstream probes that + // walk `storage.{folders,files}.path` need the RIGHT drive scope + // for each side; we thread `src_drive_id` for source probes and + // `dst_drive_id` for destination probes. + let src_scope = resolve_webdav_scope_or_405(&state, user.id, &source_path).await?; + let dst_scope = resolve_webdav_scope_or_405(&state, user.id, &destination_path).await?; + let src_drive_id = src_scope.drive_id; + let dst_drive_id = dst_scope.drive_id; + let source_path = src_scope.db_path; + let path = source_path.clone(); + let destination_path = dst_scope.db_path; // RFC 4918 §9.9.3: MOVE to self MUST return 403 Forbidden. - if destination_path == source_path { + if destination_path == path { return Err(AppError::forbidden("Cannot MOVE a resource to itself")); } + // Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move + // removes the source resource, which counts as modifying it. The + // guard runs AFTER scope resolution so its lookup keys on the DB + // path — same key `handle_lock` used when it registered the lock. + if let Some(resp) = enforce_native_lock( + &state.webdav_lock_store, + if_header_owned.as_deref(), + &source_path, + None, + ) { + return Ok(resp); + } + // Destination lock guard: MOVE also creates/replaces a resource at // the destination. If that path is locked, the same If: header must // satisfy it. @@ -2133,21 +2292,19 @@ async fn handle_move( let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; - // Probe destination existence for Overwrite semantics and 201 vs 204. let dest_existed = if let Some(resolver) = &state.path_resolver { resolver - .exists_in_drive(&destination_path, drive_id) + .exists_in_drive(&destination_path, dst_drive_id) .await .unwrap_or(false) } else { folder_service - .get_folder_by_path(&destination_path, drive_id) + .get_folder_by_path(&destination_path, dst_drive_id) .await .is_ok() || file_retrieval_service - .get_file_by_path(&destination_path, drive_id) + .get_file_by_path(&destination_path, dst_drive_id) .await .is_ok() }; @@ -2161,7 +2318,7 @@ async fn handle_move( // RFC 4918 §9.9.3: when Overwrite: T, perform a DELETE on the // destination before moving. Without this the rename/move fails // on a unique-index conflict (same name in same parent). - match resolve_or_legacy(&state, &destination_path, user.id).await { + match resolve_or_legacy(&state, &destination_path, dst_drive_id).await { Some(ResolvedResource::Folder(f)) => { folder_service .delete_folder_with_perms(&f.id, user.id) @@ -2189,7 +2346,7 @@ async fn handle_move( } let _ = file_retrieval_service; - let resolved = resolve_or_legacy(&state, &source_path, user.id) + let resolved = resolve_or_legacy(&state, &source_path, src_drive_id) .await .ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?; @@ -2213,7 +2370,7 @@ async fn handle_move( None } else { match folder_service - .get_folder_by_path(dest_parent_path, drive_id) + .get_folder_by_path(dest_parent_path, dst_drive_id) .await { Ok(parent) => { @@ -2262,13 +2419,19 @@ async fn handle_move( } } ResolvedResource::File(file) => { - if source_parent_path != dest_parent_path { + // A cross-drive move always changes the parent folder id even + // if the RELATIVE path within each drive looks the same, so + // we key the "same-parent rename" fast-path off drive id + // agreement as well. + let is_same_parent = + src_drive_id == dst_drive_id && source_parent_path == dest_parent_path; + if !is_same_parent { // RFC 4918 §9.9.5: missing destination parent → 409 Conflict. let target_parent_id = if dest_parent_path.is_empty() { None } else { let parent = folder_service - .get_folder_by_path(dest_parent_path, drive_id) + .get_folder_by_path(dest_parent_path, dst_drive_id) .await .map_err(|_| { AppError::conflict(format!( @@ -2382,12 +2545,20 @@ async fn handle_copy( // SECURITY: reject path-traversal in destination reject_path_traversal(&destination_path)?; - // Normalize through the same path-prefixing the dispatcher applied - // to source_path. See the long comment in handle_move for why this - // matters — same root-cause class of asymmetric-path bugs. - let destination_path = resolve_webdav_path(&state, user.id, &destination_path) - .await - .unwrap_or(destination_path); + // Resolve BOTH source and destination scope. Cross-drive COPY is + // permitted: `copy_file_with_perms` / `copy_folder_tree_with_perms` + // take a target folder id and don't care which drive it lives in; + // the D5 `forbid_cross_drive_move` policy applies to MOVE only, + // never to COPY (copying is non-destructive on the source side). + // Downstream probes need the right drive per side, so we thread + // `src_drive_id` for source probes and `dst_drive_id` for + // destination probes. + let src_scope = resolve_webdav_scope_or_405(&state, user.id, &source_path).await?; + let dst_scope = resolve_webdav_scope_or_405(&state, user.id, &destination_path).await?; + let src_drive_id = src_scope.drive_id; + let dst_drive_id = dst_scope.drive_id; + let source_path = src_scope.db_path; + let destination_path = dst_scope.db_path; // RFC 4918 §9.8.5: COPY to self MUST return 403 Forbidden. if destination_path == source_path { @@ -2416,21 +2587,23 @@ async fn handle_copy( let folder_service = &state.applications.folder_service; let file_management_service = &state.applications.file_management_service; - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + // Scope already resolved above; keep `path` alias for downstream code + // that still reads `path` under its original name. + let _path = source_path.clone(); // Probe destination existence for Overwrite semantics and 201 vs 204. let dest_existed = if let Some(resolver) = &state.path_resolver { resolver - .exists_in_drive(&destination_path, drive_id) + .exists_in_drive(&destination_path, dst_drive_id) .await .unwrap_or(false) } else { folder_service - .get_folder_by_path(&destination_path, drive_id) + .get_folder_by_path(&destination_path, dst_drive_id) .await .is_ok() || file_retrieval_service - .get_file_by_path(&destination_path, drive_id) + .get_file_by_path(&destination_path, dst_drive_id) .await .is_ok() }; @@ -2444,7 +2617,7 @@ async fn handle_copy( // RFC 4918 §9.8.4: when Overwrite: T, the server MUST perform a // DELETE on the destination before the copy. Without this the copy // service returns a unique-index conflict (500). - match resolve_or_legacy(&state, &destination_path, user.id).await { + match resolve_or_legacy(&state, &destination_path, dst_drive_id).await { Some(ResolvedResource::Folder(f)) => { folder_service .delete_folder_with_perms(&f.id, user.id) @@ -2472,7 +2645,7 @@ async fn handle_copy( } let _ = file_retrieval_service; - let resolved = resolve_or_legacy(&state, &source_path, user.id) + let resolved = resolve_or_legacy(&state, &source_path, src_drive_id) .await .ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?; @@ -2490,7 +2663,7 @@ async fn handle_copy( None } else { match folder_service - .get_folder_by_path(dest_parent_path, drive_id) + .get_folder_by_path(dest_parent_path, dst_drive_id) .await { Ok(parent) => { @@ -2590,25 +2763,101 @@ async fn handle_lock( ) -> Result, AppError> { let user = extract_user(&req)?; - // Determine collection-vs-file for href shape. Root + known - // folders → collection; everything else (existing files, - // lock-null on a non-existent path) → file. RFC 4918 §9.10.1 - // allows LOCK on a non-existent resource (the "lock-null - // resource" pattern used by Office save flows) — that arm - // falls through to the file href shape, matching the - // request-line shape clients send. - let is_collection = if path.is_empty() || path == "/" { - true + // Scope resolution BEFORE the collection probe so `path` becomes + // the drive-scoped DB path everywhere downstream — critically the + // `lock_store.acquire(&path, …)` call must use the SAME key that + // `enforce_native_lock` will look up from the write verbs + // (PUT/DELETE/MOVE/COPY/PROPPATCH), all of which pass the DB path. + // Locking the URL path here and looking up the DB path in PUT + // would silently defeat the lock — that's the regression this + // shape prevents. + let (drive_id, path) = if path.is_empty() || path == "/" { + (Uuid::nil(), path) } else { - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; - state - .applications - .folder_service - .get_folder_by_path(&path, drive_id) - .await - .is_ok() + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + (scope.drive_id, scope.db_path) }; + // Determine collection-vs-file for href shape AND resolve the + // target for AuthZ. Root + known folders → collection; existing + // files → file; missing path → lock-null (RFC 4918 §7.3 / + // §9.10.1, used by Office save flows). AuthZ per case: + // * Existing folder / file → `Update` on the resource. + // * Lock-null (target doesn't exist yet) → `Create` on the + // parent folder (the lock reserves the URL for a future PUT + // that would need `Create` anyway; deny here so a Viewer + // can't create a lock-null placeholder on someone else's + // namespace). + // Denial routes through `NotFound` (anti-enum), matching the + // rest of the WebDAV surface. + let (is_collection, lockable_resource) = if path.is_empty() { + (true, None) + } else if let Ok(folder) = state + .applications + .folder_service + .get_folder_by_path(&path, drive_id) + .await + { + let uuid = Uuid::parse_str(&folder.id) + .map_err(|e| AppError::internal_error(format!("Folder id is not a UUID: {e}")))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Update, + Resource::Folder(uuid), + ) + .await?; + (true, Some(Resource::Folder(uuid))) + } else if let Ok(file) = state + .applications + .file_retrieval_service + .get_file_by_path(&path, drive_id) + .await + { + let uuid = Uuid::parse_str(&file.id) + .map_err(|e| AppError::internal_error(format!("File id is not a UUID: {e}")))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Update, + Resource::File(uuid), + ) + .await?; + (false, Some(Resource::File(uuid))) + } else { + // Lock-null: authorise on the parent folder. The last `/` in + // `path` splits parent from name; empty parent means the drive + // root (which itself was already resolved above — the caller + // must have Read on it to have gotten this far via + // `resolve_webdav_scope`). + let parent_path = path.rfind('/').map(|i| &path[..i]).unwrap_or(""); + if !parent_path.is_empty() { + let parent = state + .applications + .folder_service + .get_folder_by_path(parent_path, drive_id) + .await + .map_err(|_| AppError::conflict("Parent folder not found for lock-null"))?; + let parent_uuid = Uuid::parse_str(&parent.id).map_err(|e| { + AppError::internal_error(format!("Parent folder id is not a UUID: {e}")) + })?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Create, + Resource::Folder(parent_uuid), + ) + .await?; + } + // No resource to authorise directly — the lock reserves the URL, + // downstream PUT will re-authorise via its own Create/Update. + (false, None) + }; + let _ = lockable_resource; + // Get the headers that we need let depth = req .headers() @@ -2690,13 +2939,17 @@ async fn handle_lock( type_, }; - // Try to acquire the lock (conflict detection via moka store) - let entry = lock_store.acquire(&path, lock_info).map_err(|existing| { - AppError::locked(format!( - "Resource already locked by token {}", - existing.info.token - )) - })?; + // Try to acquire the lock (conflict detection via moka store). + // `caller_user_id` is stamped on the entry so `handle_unlock` + // can enforce RFC 4918 §9.11's owner-only rule. + let entry = lock_store + .acquire(&path, lock_info, Some(user.id)) + .map_err(|existing| { + AppError::locked(format!( + "Resource already locked by token {}", + existing.info.token + )) + })?; // Generate response — collection vs file href chosen above. let href = if is_collection { @@ -2735,9 +2988,9 @@ async fn handle_lock( async fn handle_unlock( state: Arc, req: Request, - _path: String, + path: String, ) -> Result, AppError> { - let _user = extract_user(&req)?; + let user = extract_user(&req)?; // Get lock token from Lock-Token header let lock_token = req @@ -2753,6 +3006,65 @@ async fn handle_unlock( .trim_end_matches('>') .to_string(); + // RFC 4918 §9.11 owner-only check. `LockEntry.caller_user_id` + // was stamped by `handle_lock` at acquire time. When the lock + // exists AND we know the acquirer, only that user can UNLOCK. + // Denial routes through the standard authz `NotFound` anti-enum + // — a caller who neither holds the lock nor has any perm on the + // resource shouldn't learn whether the lock exists. + // + // Approximations preserved: + // * Lock entries seeded by tests (`caller_user_id = None`) fall + // through to the Update-based check below — they were never + // bound to a real user. + // * If the token isn't in the store at all (expired, never + // existed) we skip the owner check and let the `release` + // call below return the RFC-standard 409. + let lock_entry = state.webdav_lock_store.get_by_token(&token); + if let Some(entry) = &lock_entry + && let Some(owner_id) = entry.caller_user_id + && owner_id != user.id + { + tracing::info!( + target: "audit", + event = "webdav.unlock_denied", + reason = "not_lock_owner", + caller_id = %user.id, + lock_owner_id = %owner_id, + token = %token, + "👮🏻‍♂️ UNLOCK refused: caller does not own the lock", + ); + return Err(AppError::not_found(format!( + "Lock token not found or already expired: {}", + token + ))); + } + + // Defence-in-depth for the test-seeded / legacy `caller_user_id + // = None` case: require `Update` on the target resource so a + // Read-only grantee still can't unlock. Uses the URL path (the + // lock's target) to resolve the resource. Missing target → skip + // (lock-null unlock is legitimate). + if let Some(entry) = &lock_entry + && entry.caller_user_id.is_none() + && !path.is_empty() + && path != "/" + { + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let db_path = scope.db_path; + if let Some(resource) = match resolve_or_legacy(&state, &db_path, drive_id).await { + Some(ResolvedResource::Folder(f)) => Uuid::parse_str(&f.id).ok().map(Resource::Folder), + Some(ResolvedResource::File(f)) => Uuid::parse_str(&f.id).ok().map(Resource::File), + None => None, + } { + state + .authorization + .require(Subject::User(user.id), Permission::Update, resource) + .await?; + } + } + // Remove the lock from the store if !state.webdav_lock_store.release(&token) { // RFC 4918 §9.11.1: If the lock does not exist, return 409 Conflict diff --git a/tests/api/run.sh b/tests/api/run.sh index e0f0e992..b90d182f 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -185,6 +185,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/cross_drive_move.hurl" \ "$API_DIR/cross_drive_copy.hurl" \ "$API_DIR/webdav_dead_properties.hurl" \ + "$API_DIR/webdav_drive_root.hurl" \ + "$API_DIR/webdav_permissions.hurl" \ "$API_DIR/webdav_nested_move_cascade.hurl" \ "$API_DIR/wopi_authz.hurl" diff --git a/tests/api/webdav_drive_root.hurl b/tests/api/webdav_drive_root.hurl new file mode 100644 index 00000000..a3e220c2 --- /dev/null +++ b/tests/api/webdav_drive_root.hurl @@ -0,0 +1,229 @@ +# ============================================================= +# OxiCloud — WebDAV drive-root URL scheme +# ============================================================= +# Exercises the native WebDAV URL scheme documented in +# `src/interfaces/api/handlers/webdav_handler.rs::resolve_webdav_scope`: +# +# Default deployment (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`): +# * `/webdav/` → default drive's contents +# * `/webdav/@drive/` → drive listing (per-drive +# virtual folders) +# * `/webdav/@drive//…` → explicit drive by UUID +# * `/webdav/@drive//…` → explicit drive by name +# +# Coverage: +# 1. Login, capture JWT +# 2. Resolve caller's default drive (id + display name) +# 3. Create a magic folder under the home root via REST +# 4. PROPFIND `/webdav/` — Depth: 1 lists the magic folder as +# an immediate child of the default drive. This is the +# user-visible bug fix: pre-refactor, `/webdav/` returned a +# drive listing instead of the default drive's contents. +# 5. PROPFIND `/webdav/@drive/` — Depth: 1 lists each drive as +# a virtual child (at least the caller's default is present). +# 6. PROPFIND `/webdav/@drive//` — descends into the +# selected drive by UUID; magic folder appears here too. +# 7. PROPFIND `/webdav/@drive//` — same via display name. +# 8. Cleanup: DELETE the magic folder via REST. +# +# The magic folder name embeds a run-scoped marker so parallel +# `hurl --jobs N` runs don't step on each other and repeat runs +# against a shared DB don't collide. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login, capture JWT +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Resolve caller's default drive (id + display name). +# `GET /api/drives` returns rows in a stable order: +# the caller's default personal drive first, then by +# display name. See `DriveRepository::list_readable_by`. +# `default_for_user` on the DTO is present-only for +# default rows (`Option` with `skip_serializing_if`), +# so `$[0]` — combined with the stable order — is the +# default drive for a fresh admin account. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +default_drive_id: jsonpath "$[0].id" +default_drive_name: jsonpath "$[0].name" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Resolve the caller's home root folder id. +# A default personal drive has exactly one root folder +# (the drive-root itself). We need its id to create the +# magic folder as its child. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +home_folder_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Create a magic folder under the home root via REST. +# The name is deterministic-yet-unique so PROPFIND +# assertions below can find it by exact string match, +# and parallel test runs can't collide. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "hurl-drive-root-magic-marker", + "parent_id": "{{home_folder_id}}" +} + +HTTP 201 +[Captures] +magic_folder_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — PROPFIND on `/webdav/` (bare root). The default +# deployment maps this to the caller's DEFAULT drive +# contents, so Depth: 1 must include the magic folder. +# +# Pre-refactor this returned a drive listing instead — +# the exact regression that broke back-compat with +# pre-multi-drive WebDAV clients. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 6 — PROPFIND on `/webdav/@drive/`. This is the explicit +# drive picker — Depth: 1 returns one virtual child +# per drive the caller has Read on. The default drive +# must appear (by its display name). +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 7 — PROPFIND on `/webdav/@drive//`. The explicit +# by-UUID selector — descends INTO the chosen drive. +# Depth: 1 lists that drive's top-level children — +# the magic folder must be one of them. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{default_drive_id}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 8 — PROPFIND on `/webdav/@drive//`. The explicit +# by-name selector — same result as the UUID form. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{default_drive_name}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Reject MKCOL at `/webdav/@drive/` (bare pseudo-root). +# The drive-listing target has no writable parent +# folder — 405 Method Not Allowed. This guard prevents +# a client from silently succeeding at "creating a +# drive by MKCOL" (the drive-create surface is +# `POST /api/drives`, not WebDAV). +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/ +Authorization: Bearer {{token}} + +HTTP 405 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Reject MKCOL at `/webdav/@drive/`. +# `` gets interpreted as a drive selector; +# no drive with that name/UUID exists → 404. Sits +# adjacent to Step 9 so any future maintainer touching +# the pseudo-root rejection sees BOTH shapes at once +# (bare listing = 405, unknown selector = 404). +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/hurl-not-a-real-drive +Authorization: Bearer {{token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Reject PUT at `/webdav/@drive//x.txt`. +# Same rejection shape as MKCOL — trying to write a +# file into a non-existent drive. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/hurl-not-a-real-drive/probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +probe +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 11b — Reject PUT at `/webdav/@drive/test.txt`. The URL +# segment immediately after `@drive/` is ALWAYS a +# drive selector — never a filename. A caller that +# bookmarks a file URL under `@drive` with a name +# that doesn't match any drive must get 404, not +# silently create a file at the drive-listing level. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/test.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +probe +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Cleanup: DELETE the magic folder via REST so +# subsequent test runs / other hurl files don't see +# our marker. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{magic_folder_id}} +Authorization: Bearer {{token}} + +HTTP 204 diff --git a/tests/api/webdav_permissions.hurl b/tests/api/webdav_permissions.hurl new file mode 100644 index 00000000..8b930239 --- /dev/null +++ b/tests/api/webdav_permissions.hurl @@ -0,0 +1,300 @@ +# ============================================================= +# OxiCloud — WebDAV per-role permissions + cross-drive MOVE policy +# ============================================================= +# End-to-end coverage for the two WebDAV authz axes exposed by the +# `@drive` URL scheme: +# +# 1. Per-role gates through the drive-scope resolver: a Viewer on a +# shared drive can PROPFIND/GET but cannot MKCOL/PUT/MOVE. An +# Editor can. AuthZ denials return `NotFound` (anti-enum), so +# a probing caller can't tell a genuinely-missing folder from +# one they simply lack Create on. +# +# 2. Drive policy `forbid_cross_drive_move` gates MOVE at the +# SOURCE drive (see `DrivePolicies::refuse_cross_drive_move` +# in `src/domain/entities/drive.rs`) — even a fully-authorised +# Editor can't move content OUT of a drive whose owner has +# forbidden cross-drive movement. Rejection is 405 +# (`ErrorKind::UnsupportedOperation` → `METHOD_NOT_ALLOWED`). +# +# Assumes the default `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"` config — +# runs alongside the other tests in `tests/api/run.sh`. Uses the +# `@drive/` selector so the paths don't collide with any +# drive-name-collision oddities. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login as admin (bootstrapped by `setup.hurl`). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" +admin_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Create a fresh user "webdav_bob" via the admin +# endpoint, log him in. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "webdav_bob", + "password": "WebdavBobPassword1!", + "email": "webdav_bob@example.com", + "role": "user" +} + +HTTP 201 +[Captures] +bob_user_id: jsonpath "$.id" + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "webdav_bob", "password": "WebdavBobPassword1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + + +# Capture Bob's default personal drive id — used by the cross-drive +# MOVE scenario. Bob is not a member of any shared drive yet, so his +# `/api/drives` listing has exactly one entry (his own default). +GET {{base_url}}/api/drives +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Captures] +bob_personal_drive_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Admin creates a shared drive owned by admin. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "webdav-perm-shared", + "owner": { "type": "user", "id": "{{admin_user_id}}" } +} + +HTTP 201 +[Captures] +shared_drive_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Grant Bob VIEWER on the shared drive via /api/grants. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "drive", "id": "{{shared_drive_id}}" }, + "role": "viewer" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Bob (VIEWER) CAN PROPFIND the shared drive root. +# Depth 0 to keep the assertion minimal; a 207 with the +# drive's own href suffices as "Bob has Read". +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/ +Authorization: Bearer {{bob_token}} +Depth: 0 + +HTTP 207 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Bob (VIEWER) CANNOT MKCOL on the shared drive. +# `authz.require(Create, Folder)` denial returns +# `DomainError::not_found` (anti-enum), which maps to 404. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-folder +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Bob (VIEWER) CANNOT PUT a file. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-file.txt +Authorization: Bearer {{bob_token}} +Content-Type: text/plain +``` +viewer should not upload +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Admin creates a probe folder in the shared drive so +# the Editor-can-rename step below has a real target. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder +Authorization: Bearer {{admin_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Bob (VIEWER) CANNOT MOVE (rename) the probe folder. +# MOVE requires Update on the source, which Viewer +# doesn't have. Same anti-enum 404 shape. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Promote Bob from VIEWER to EDITOR. +# `PATCH /api/drives/{id}/members/{subject-type}/{id}` +# mutates the role in-place. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{bob_user_id}} +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "role": "editor" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Bob (EDITOR) CAN MKCOL a new folder. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder +Authorization: Bearer {{bob_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Bob (EDITOR) CAN PUT a file. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder/hello.txt +Authorization: Bearer {{bob_token}} +Content-Type: text/plain +``` +editor uploaded content +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 13 — Bob (EDITOR) CAN MOVE (rename) the probe folder. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 14 — Bob puts a file in his OWN personal drive as the +# source for the cross-drive MOVE test below. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Content-Type: text/plain +``` +cross-drive probe payload +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 15 — Admin flips `forbid_cross_drive_move` ON for Bob's +# PERSONAL drive. The policy sits on the SOURCE drive +# per `DrivePolicies::refuse_cross_drive_move`; only +# OxiCloud-admin can PATCH policies. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "forbid_cross_drive_move": true } + +HTTP 200 +[Asserts] +jsonpath "$.forbid_cross_drive_move" == true + + +# ───────────────────────────────────────────────────────────── +# Step 16 — Bob tries to MOVE `xdrive-probe.txt` from his +# PERSONAL drive to the SHARED drive. Blocked at the +# service layer by the policy — `OperationNotSupported` +# maps to 405 Method Not Allowed. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt + +HTTP 405 + + +# ───────────────────────────────────────────────────────────── +# Step 17 — Admin flips the policy OFF. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "forbid_cross_drive_move": false } + +HTTP 200 +[Asserts] +jsonpath "$.forbid_cross_drive_move" == false + + +# ───────────────────────────────────────────────────────────── +# Step 18 — Bob retries the same MOVE. Now the policy is off, +# Bob has Update on source (his own personal drive) + +# Create on dest parent (Editor on shared drive), so +# the move succeeds. 201 on rename/move to a new URL, +# per `handle_move`'s existing convention. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 19 — Verify the destination now exists and the source +# is gone. Both PROPFINDs use Bob's token to also +# re-confirm the AuthZ gates on the destination side. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Depth: 0 + +HTTP 207 + + +PROPFIND {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Depth: 0 + +HTTP 404 diff --git a/tests/common/server-webdav-drive-root.env b/tests/common/server-webdav-drive-root.env new file mode 100644 index 00000000..3fafb75b --- /dev/null +++ b/tests/common/server-webdav-drive-root.env @@ -0,0 +1,85 @@ +# Shared test-server environment variables. +# Sourced by tests/api/run.sh (shell) and read by tests/e2e/playwright.config.ts (Node). +# Do NOT include OXICLOUD_SERVER_PORT or OXICLOUD_STORAGE_PATH here — +# each test suite sets those to avoid port/directory conflicts. + +DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_STATIC_PATH=./static +OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars +OXICLOUD_ENABLE_AUTH=true +OXICLOUD_ENABLE_TRASH=true +OXICLOUD_ENABLE_SEARCH=true +OXICLOUD_ENABLE_FILE_SHARING=true +OXICLOUD_ENABLE_MUSIC=true +OXICLOUD_EXPOSE_SYSTEM_USERS=true +OXICLOUD_WOPI_ENABLED=true +# Fixed secret so the Hurl WOPI test can hand-craft valid access +# tokens with a known signing key. Prod deployments MUST override +# this to a random per-deployment value. +OXICLOUD_WOPI_SECRET=test-wopi-secret-do-not-use-in-prod-do-not-use-in-prod +# Discovery URL points at a black hole — VERB endpoints don't need +# discovery, and the WOPI Hurl suite deliberately does NOT touch +# `/api/wopi/editor-url` (the only path that would fetch it), so +# an unreachable URL keeps startup fast and hermetic. +OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:9100/discovery.xml +OXICLOUD_WOPI_TOKEN_TTL_SECS=3600 +OXICLOUD_OIDC_ENABLED=false + +OXICLOUD_NEXTCLOUD_ENABLED=true + +# Test-only sweep triggers (`/api/admin/internal/trigger-sweep`, +# `/api/admin/internal/trigger-gc`). Off by default in production; +# the Hurl suite needs them to assert post-delete quota convergence +# without waiting out the 600 s reconciliation tick. +OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true + +RUST_LOG="warn,audit=info,sqlx::migrate=info" +#RUST_LOG="warn,audit=info,oxicloud::quota=debug" +#RUST_LOG=debug +#RUST_LOG=info + +# Per-chunk upload cap, exercised by chunked_upload_cap.hurl. +# 4 MiB: lets the existing grants.hurl single-chunk test (2.76 MB) pass +# under the cap, while the cap test sends a 5 MiB fixture to trigger 413. +OXICLOUD_CHUNK_MAX_BYTES=4194304 + +# Direct-PUT (non-chunked) cap, exercised by chunked_upload_cap.hurl. +# 4 MiB: same threshold as the chunked cap so the existing 5 MiB +# fixture (chunk-over-cap-5mb.bin) can prove BOTH caps with one +# generated file. All existing direct-PUT tests +# (test_dedup_webdav_multichunk.sh = 2.76 MB, _ref_count = ~66 KB, +# _nextcloud_put_blake3 = 32 B) stay safely under this cap. +OXICLOUD_DIRECT_PUT_MAX_BYTES=4194304 + +# grow up limits for tests +OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600 +OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600 +OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600 + +# Magic-link / external-users flow (PR 9). The mock SMTP captures every +# outbound message in-process so external_users.hurl can retrieve the +# invitation body and follow the magic-link URL. The `SMTP_FROM` value +# is required so the mock can build a valid Message; host/port are +# irrelevant in mock mode but kept set for completeness. +OXICLOUD_SMTP_MOCK=true +OXICLOUD_SMTP_HOST=localhost +OXICLOUD_SMTP_PORT=25 +OXICLOUD_SMTP_FROM='OxiCloud Tests ' +OXICLOUD_SMTP_TLS=none +OXICLOUD_ALLOW_EXTERNAL_USERS=true + +# PR 12 — magic-link rate-limit caps lowered so external_users.hurl can +# exercise the cap behaviour with a small, deterministic request count. +# Production defaults are 50 / 5 / 200 respectively (see example.env). +OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3 +OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2 +OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=50 + +# permits IP spoofing for tests +OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0 + +OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true + +# /webdav/ will points directly to list of drives +OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="" diff --git a/tests/common/wipe-storage.sh b/tests/common/wipe-storage.sh index 5ac8d1c5..4d0aff19 100644 --- a/tests/common/wipe-storage.sh +++ b/tests/common/wipe-storage.sh @@ -34,9 +34,10 @@ wipe_storage() { fi # Sanity check: must end in tests//storage where is - # lowercase alphanumeric. Stops `rm -rf` from ever running against - # an unexpected expansion of a callerʼs path. - if [[ ! "$path" =~ /tests/[a-z0-9]+/storage$ ]]; then + # lowercase alphanumeric (hyphens allowed so multi-word runner names + # like `webdav-drive-root` pass). Stops `rm -rf` from ever running + # against an unexpected expansion of a caller's path. + if [[ ! "$path" =~ /tests/[a-z0-9][a-z0-9-]*/storage$ ]]; then echo "[wipe_storage] ERROR: '$path' does not match .../tests//storage — refusing to wipe" >&2 return 1 fi diff --git a/tests/webdav-drive-root/drive_root_empty_config.hurl b/tests/webdav-drive-root/drive_root_empty_config.hurl new file mode 100644 index 00000000..5102e012 --- /dev/null +++ b/tests/webdav-drive-root/drive_root_empty_config.hurl @@ -0,0 +1,186 @@ +# ============================================================= +# OxiCloud — WebDAV drive-root URL scheme, `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` variant +# ============================================================= +# Companion to `webdav_drive_root.hurl`. That file exercises the +# default config (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`); this +# one exercises the empty-string config where `/webdav/` IS the +# drive listing and there's no default-drive shortcut. +# +# Server env for this test: `tests/common/server-webdav-drive-root.env` +# sets `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""`. This file assumes that +# config is active — it is NOT part of the standard `run.sh` +# invocation (which starts the default-config server). +# +# Coverage: +# 1. Login, capture JWT +# 2. Resolve caller's default drive (id + display name) +# 3. Create a magic folder under the home root via REST +# 4. PROPFIND `/webdav/` — drive listing (default drive +# appears as a virtual child under its display name). +# 5. PROPFIND `/webdav//` — descend into a drive by +# UUID. Magic folder appears. +# 6. PROPFIND `/webdav//` — descend into a drive by +# display name. Magic folder appears. +# 7. `/webdav/@drive/` returns 404 in this mode — the sigil +# has no reserved meaning when `webdav_drive_listing_prefix=""`. +# A drive genuinely named `@drive` would resolve here; the +# 404 comes from "no such drive," not the sigil. +# 8. Cleanup: DELETE the magic folder via REST. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login, capture JWT +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Resolve caller's default drive (id + display name). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +default_drive_id: jsonpath "$[0].id" +default_drive_name: jsonpath "$[0].name" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Resolve the caller's home root folder id. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +home_folder_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Create a magic folder under the home root via REST. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "hurl-drive-root-empty-magic-marker", + "parent_id": "{{home_folder_id}}" +} + +HTTP 201 +[Captures] +magic_folder_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — PROPFIND on `/webdav/` (bare root). With +# `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` this IS the drive +# listing — the default drive appears as a virtual +# child under its display name. The magic folder does +# NOT appear here (it lives one level deeper). +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists +# Magic folder is one level deeper — must NOT show up at root. +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" not exists + + +# ───────────────────────────────────────────────────────────── +# Step 6 — PROPFIND on `/webdav//`. Descends into the +# default drive; magic folder is a top-level child. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/{{default_drive_id}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 7 — PROPFIND on `/webdav//`. Same descent via +# display name. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/{{default_drive_name}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 8 — `/webdav/@drive/` has no reserved meaning in the +# empty-config mode. `@drive` is treated as a plain +# drive selector; no drive by that name → 404. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Reject MKCOL at `/webdav/` (bare pseudo-root). +# In the empty-config mode `/webdav/` IS the drive +# listing — there's no writable parent, so 405 +# Method Not Allowed. This guard prevents a client +# from creating something at "root" that shadows a +# drive name. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/ +Authorization: Bearer {{token}} + +HTTP 405 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Reject MKCOL at `/webdav/`. The first +# URL segment is the drive selector in this config; +# an unknown selector yields 404. A client cannot +# "create a drive" via MKCOL — the drive-create +# surface is `POST /api/drives`. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/hurl-not-a-real-drive +Authorization: Bearer {{token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Reject PUT at `/webdav//x.txt`. Same +# rejection shape as MKCOL. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/hurl-not-a-real-drive/probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +probe +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Cleanup: DELETE the magic folder via REST. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{magic_folder_id}} +Authorization: Bearer {{token}} + +HTTP 204 diff --git a/tests/webdav-drive-root/run.sh b/tests/webdav-drive-root/run.sh new file mode 100755 index 00000000..fc8d4631 --- /dev/null +++ b/tests/webdav-drive-root/run.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# WebDAV drive-root URL-scheme variant runner. +# +# Exercises `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` — the config where the +# WebDAV `@drive` path segment is disabled and `/webdav/` IS the +# drive listing. `tests/api/webdav_drive_root.hurl` covers the +# default `"@drive"` config in the main API run; this runner +# starts a separately-configured server to cover the empty-string +# case, mirroring the OIDC runner's shape. +# +# Usage (from repo root): +# bash tests/webdav-drive-root/run.sh +# +# Prerequisites: docker, cargo, hurl ≥ 4.0 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +TEST_DIR="$REPO_ROOT/tests/webdav-drive-root" + +# shellcheck source=test.env +source "$TEST_DIR/test.env" + +SERVER_PORT="${base_url##*:}" + +log() { echo "[webdav-drive-root] $*"; } +die() { echo "[webdav-drive-root] ERROR: $*" >&2; exit 1; } + +wait_for_http() { + local url="$1" timeout="${2:-60}" + local deadline=$(( $(date +%s) + timeout )) + until curl -sf "$url" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url" + sleep 1 + done +} + +# ── Teardown (always runs on exit) ──────────────────────────────────────────── + +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud server (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. Load the drive-root-variant server env + port ────────────────────────── + +set -a +# shellcheck source=../common/server-webdav-drive-root.env +source "$COMMON/server-webdav-drive-root.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/webdav-drive-root/storage" +set +a + +# shellcheck source=../common/wipe-storage.sh +source "$COMMON/wipe-storage.sh" +wipe_storage "$OXICLOUD_STORAGE_PATH" + +# ── 3. Start OxiCloud server with the drive-root-variant config ─────────────── + +BUILD_TARGET="${BUILD_TARGET:-debug}" +OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" + +if [[ ! -x "$OXICLOUD_BIN" ]]; then + log "Building OxiCloud server ($BUILD_TARGET)..." + case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;; + *) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;; + esac +fi + +log "Starting OxiCloud server with WEBDAV_DRIVE_LISTING_PREFIX='' on port $SERVER_PORT..." +"$OXICLOUD_BIN" --config "$COMMON/server-webdav-drive-root.env" & +SERVER_PID=$! +log "Waiting for server at $base_url..." +wait_for_http "$base_url/ready" 120 +log "Server is ready." + +# ── 4. Run Hurl tests ───────────────────────────────────────────────────────── +# +# `setup.hurl` from the shared api/ suite bootstraps the initial admin +# account via `POST /api/setup` — the endpoint locks after the first +# admin exists, so it's a one-shot idempotency-by-server-state seed. +# We reuse the file rather than duplicating the setup body so credential +# / schema changes in the api tests automatically flow here. + +log "Running Hurl tests..." +hurl --variables-file "$TEST_DIR/test.env" \ + --file-root "$REPO_ROOT/tests" \ + --test --jobs 1 \ + "$REPO_ROOT/tests/api/setup.hurl" \ + "$TEST_DIR/drive_root_empty_config.hurl" + +log "webdav-drive-root tests passed." diff --git a/tests/webdav-drive-root/test.env b/tests/webdav-drive-root/test.env new file mode 100644 index 00000000..5da9de60 --- /dev/null +++ b/tests/webdav-drive-root/test.env @@ -0,0 +1,9 @@ +# Test credentials for the WebDAV drive-root variant runner — NOT real secrets. +# Runs on a separate port from tests/api and tests/webdav so a +# `just api-test` chain doesn't collide when the previous runner's +# teardown is still in progress. +base_url=http://localhost:8089 +username=admin +email=admin@example.com +# gitguardian:ignore +password=TestPassword1!