diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index e96fe674..f21a613a 100644 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -4,8 +4,10 @@ use axum::{ response::Response, }; use std::sync::Arc; +use uuid::Uuid; use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase}; +use crate::application::ports::storage_ports::StorageUsagePort; use crate::common::di::AppState; use crate::common::mime_detect::filename_from_path; use crate::interfaces::errors::AppError; @@ -13,6 +15,79 @@ use crate::interfaces::upload_ingest::{ discard_ingested, ingest_stream_to_cas, stream_body_to_path, stream_from_files, }; +/// Per-chunk quota gate (D4 / project_drive_quota_timing). +/// +/// Pre-D4 the NC chunked path never declared a total size up front, so +/// quota only fired at the final MOVE — meaning a client could waste GB +/// of upload bandwidth before learning it was over. The drive is known +/// from the session's chroot and the user is on the session, so we can +/// gate at every wire moment now: +/// +/// - MKCOL: refuse if either the drive or the user envelope is +/// already at quota (call with `additional = 0`). +/// - PUT : refuse if `used + already_uploaded_for_session + +/// content-length` would breach either cap. +/// `already_uploaded_for_session` is the sum of chunk sizes the +/// session already holds on disk. +/// - MOVE : defence in depth via `file_upload_service`'s own gates. +/// +/// Both checks run because the two caps cover different cases: +/// `check_drive_quota` is the per-drive `drives.quota_bytes` cap +/// (shared drives carry a value; personal drives are `NULL` and +/// short-circuit to OK). `check_storage_quota` is the user envelope +/// `users.storage_quota_bytes` that caps the SUM across the caller's +/// personal drives (shared-drive uploads short-circuit because the +/// envelope only sums personal drives — see +/// `project_user_envelope_quota_model`). Mirrors what every other +/// upload entry point (multipart, native chunked, delta, instant) +/// already does. +async fn refuse_if_over_quota( + state: &AppState, + user_id: Uuid, + drive_id: Uuid, + additional: u64, +) -> Result<(), AppError> { + let Some(svc) = state.storage_usage_service.as_ref() else { + // Quota tracking disabled in this config; MOVE-time gate + // remains authoritative. + return Ok(()); + }; + svc.check_storage_quota(user_id, additional) + .await + .map_err(AppError::from)?; + svc.check_drive_quota(drive_id, additional) + .await + .map_err(AppError::from) +} + +/// Sum of bytes already accepted into a chunked-upload session. +/// +/// Reads the session directory once via `list_chunks` and totals every +/// chunk's on-disk size. O(N) stat calls per check, but N is the chunk +/// count (NC clients use 10 MB chunks by default — a 10 GB upload sits +/// around 1000 entries; PUT throughput dominates the cost). A +/// per-session counter file would amortise it to O(1) but adds a +/// separate write-and-sync path with its own crash semantics — defer +/// until profiling actually demands it. +async fn session_bytes_so_far( + nc: &crate::common::di::NextcloudServices, + username: &str, + upload_id: &str, +) -> Result { + let listing = nc + .chunked_uploads + .list_chunks(username, upload_id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?; + let Some(listing) = listing else { + // Missing session — handler maps this elsewhere; treat as zero + // here so the gate doesn't fire spuriously on the very first + // chunk after MKCOL (race-tolerant). + return Ok(0); + }; + Ok(listing.chunks.iter().map(|c| c.size).sum()) +} + /// Dispatch Nextcloud chunked upload WebDAV requests. /// /// Routes: @@ -145,6 +220,13 @@ fn xml_escape(s: &str) -> String { } /// MKCOL — create upload session directory. +/// +/// Quota gate (D4): refuse 507 if the bound drive is already at quota, +/// before allocating the session directory. The chunked path doesn't +/// declare a total size up front — `additional = 0` so the gate only +/// fires when the drive is already exactly full (or beyond, after a +/// burst of concurrent writes). Subsequent PUTs run the proper +/// "used + session_so_far + chunk" projection. async fn handle_mkcol( state: Arc, session: &crate::interfaces::nextcloud::session::NcSession, @@ -156,6 +238,9 @@ async fn handle_mkcol( .as_ref() .ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?; + let chroot = session.require_chroot()?; + refuse_if_over_quota(&state, user.id, chroot.drive_id, 0).await?; + nc.chunked_uploads .create_session(&user.username, upload_id) .await @@ -194,6 +279,22 @@ async fn handle_put_chunk( return Err(AppError::bad_request("Missing chunk name")); } + // Per-chunk quota gate (D4): refuse 507 BEFORE accepting body + // bytes when `drive.used_bytes + session_so_far + chunk_size` + // would cross the drive cap. Closes the wasted-bandwidth wart + // where over-quota clients only learned at MOVE. + // + // Without a Content-Length we can't project ahead — fall back to + // the assemble-time check. NC desktop / Android / iOS clients + // always send CL on PUT chunks (they read the chunk file into a + // length-known body), so this branch is rare in practice. + let chroot = session.require_chroot()?; + if let Some(chunk_size) = content_length_from(&req) { + let so_far = session_bytes_so_far(nc, &user.username, upload_id).await?; + let projected = so_far.saturating_add(chunk_size); + refuse_if_over_quota(&state, user.id, chroot.drive_id, projected).await?; + } + let chunk_path = nc .chunked_uploads .safe_chunk_path(&user.username, upload_id, chunk_name) @@ -384,6 +485,17 @@ async fn handle_abort( .unwrap()) } +/// Read `Content-Length` off a request as a `u64`. Returns `None` if +/// the header is absent or malformed — the PUT-chunk quota gate +/// (`handle_put_chunk`) treats that as "skip the early gate, the +/// stream cap + MOVE-time check will still catch over-quota writes". +fn content_length_from(req: &Request) -> Option { + req.headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) +} + /// Extract the file subpath from a Destination header pointing to the files DAV namespace. /// /// For full URLs the host is ignored — only the path component is used. diff --git a/tests/webdav/test_nextcloud_chunked_upload_cap.sh b/tests/webdav/test_nextcloud_chunked_upload_cap.sh index 681a4089..a2e4dd96 100755 --- a/tests/webdav/test_nextcloud_chunked_upload_cap.sh +++ b/tests/webdav/test_nextcloud_chunked_upload_cap.sh @@ -19,6 +19,17 @@ # MOVE → verify the assembled file's BLAKE3 over REST. # 2. CAP REJECTION — MKCOL a fresh session → PUT a 5 MiB chunk → # 413 Payload Too Large. +# 3. QUOTA REJECTION (D4 / per-chunk gate) — tighten the caller's +# storage envelope to 100 B, MKCOL a fresh session (still under +# cap, used=0), then PUT a 200 B chunk → 507 Insufficient +# Storage. Pre-D4 the chunked path never gated until the final +# MOVE — clients could waste GB of upload before learning they +# were over. Validates `refuse_if_over_quota` in +# `uploads_handler::handle_put_chunk` runs the +# `used + session_so_far + content_length` projection +# ahead of accepting body bytes. Admin's original quota is +# restored on exit so subsequent tests in the suite are +# unaffected. # # Prerequisites: # - Server running at $base_url with admin credentials (test.env). @@ -88,6 +99,12 @@ EXPECTED_BLAKE3="b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5 REMOTE_NAME="nc-chunked-cap-test.txt" UPLOAD_ID_OK="oxi-cap-ok-$(date +%s)" UPLOAD_ID_BIG="oxi-cap-big-$(date +%s)" +UPLOAD_ID_QUOTA="oxi-cap-quota-$(date +%s)" +# 200 B fixture for the D4 quota-gate case. Lives in $TMPDIR so it +# never lands in `tests/fixtures/` — generated on the fly, deleted +# by the EXIT trap. mktemp keeps the path race-free across parallel +# runs. +FIXTURE_200B="" echo echo "=== NextCloud chunked upload: cap + streaming ===" @@ -110,8 +127,34 @@ APP_PASSWORD_ID=$(jq -r '.id' <<<"$APP_PASSWORD_RESPONSE") || fail "Failed to mint NC app password: $APP_PASSWORD_RESPONSE" echo " app password minted (id=$APP_PASSWORD_ID)" -# Clean up the app password when the script exits (success or fail). -trap '[[ -n "${APP_PASSWORD_ID:-}" ]] && rest_delete "/api/auth/app-passwords/$APP_PASSWORD_ID" > /dev/null || true' EXIT +# Capture admin's id + current envelope quota up front so Case 3 +# can tighten the cap and the EXIT trap can restore it on any +# failure path. `storage_quota_bytes == 0` is the unlimited +# sentinel (see `check_storage_quota`); we read it back here in +# case a prior test set a real value. +ADMIN_ID=$(rest_get "/api/auth/me" | jq -r '.id') +[[ -n "$ADMIN_ID" && "$ADMIN_ID" != "null" ]] || fail "Failed to read admin user id" +ORIGINAL_ADMIN_QUOTA=$(rest_get "/api/auth/me" | jq -r '.storage_quota_bytes // 0') + +# Single cleanup on exit: +# - restore admin's original storage envelope (in case Case 3 +# fired and we exited before its own restore), +# - revoke the test app password, +# - drop the on-the-fly 200 B fixture. +cleanup_test() { + if [[ -n "${ADMIN_ID:-}" ]]; then + curl -s -X PUT \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"quota_bytes\":${ORIGINAL_ADMIN_QUOTA}}" \ + "$base_url/api/admin/users/$ADMIN_ID/quota" > /dev/null || true + fi + if [[ -n "${APP_PASSWORD_ID:-}" ]]; then + rest_delete "/api/auth/app-passwords/$APP_PASSWORD_ID" > /dev/null || true + fi + [[ -n "${FIXTURE_200B:-}" && -f "$FIXTURE_200B" ]] && rm -f "$FIXTURE_200B" +} +trap cleanup_test EXIT # Idempotent cleanup of any leftover file from a previous failed run. HOME_FOLDER_ID=$(rest_get "/api/folders" | jq -r '.[0].id') @@ -126,7 +169,7 @@ fi # ── Case 1: SUCCESS path ────────────────────────────────────────────────────── echo -echo "[1/2] SUCCESS path — MKCOL → PUT → MOVE → verify BLAKE3" +echo "[1/3] SUCCESS path — MKCOL → PUT → MOVE → verify BLAKE3" # 1a. Create chunked-upload session. STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_OK") @@ -167,7 +210,7 @@ purge_from_trash "$REMOTE_NAME" # ── Case 2: CAP REJECTION ───────────────────────────────────────────────────── echo -echo "[2/2] CAP REJECTION — 5 MiB chunk on a 4 MiB cap → 413" +echo "[2/3] CAP REJECTION — 5 MiB chunk on a 4 MiB cap → 413" # 2a. Fresh session. STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_BIG") @@ -192,6 +235,74 @@ STATUS=$(nc_req DELETE "/remote.php/dav/uploads/$username/$UPLOAD_ID_BIG") [[ "$STATUS" =~ ^(204|404)$ ]] || fail "DELETE abandoned session: got $STATUS" pass "DELETE abandoned session (status=$STATUS)" +# ── Case 3: QUOTA REJECTION (D4 per-chunk gate) ─────────────────────────────── + +echo +echo "[3/3] QUOTA REJECTION — envelope tightened to (used + 100 B), PUT 200 B chunk → 507" + +# 3a. Compute the tight quota dynamically: admin has accumulated +# `used_bytes` from every earlier test in the suite, so a hard- +# coded "100 B" cap would trip MKCOL (`used + 0 > 100`). Read +# the current cached envelope and set the cap to +# `current + 100` — leaves enough headroom that MKCOL passes +# (`used + 0 = used < used + 100`) while a 200 B chunk PUT +# overflows by exactly 100 (`used + 0 + 200 > used + 100`). +CURRENT_USED=$(rest_get "/api/auth/me" | jq -r '.storage_used_bytes') +[[ -n "$CURRENT_USED" && "$CURRENT_USED" != "null" ]] || fail "Failed to read current used_bytes" +TIGHT_QUOTA=$(( CURRENT_USED + 100 )) + +# 3b. Tighten admin's storage envelope. The pre-existing +# `cleanup_test` EXIT trap restores `ORIGINAL_ADMIN_QUOTA` so a +# mid-test failure doesn't leave the suite running under a +# barely-headroom cap. +curl -s -X PUT \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"quota_bytes\":${TIGHT_QUOTA}}" \ + "$base_url/api/admin/users/$ADMIN_ID/quota" > /dev/null + +# 3c. Generate the 200 B fixture in $TMPDIR — never lands in +# `tests/fixtures/` (avoids polluting the committed dir + the +# gitignore list). +FIXTURE_200B=$(mktemp -t nc-chunked-quota-200b.XXXXXX.bin) +dd if=/dev/zero of="$FIXTURE_200B" bs=1 count=200 status=none + +# 3d. Fresh session. MKCOL gate projects `used + 0` against the +# tight cap; with quota = used + 100 the projection sits 100 B +# under the limit so MKCOL passes. The real gate fires at the +# PUT below. +STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_QUOTA") +[[ "$STATUS" =~ ^(201|204)$ ]] || fail "MKCOL quota session: got $STATUS" +pass "MKCOL quota session (status=$STATUS)" + +# 3e. PUT a 200 B chunk → expect 507. The handler reads +# Content-Length (200), sums on-disk chunks for this session +# (0), and runs `check_storage_quota(admin_id, 200)`: +# used + 200 > used + 100 → QuotaExceeded → 507. +# Pre-D4 this would have returned 201 and the whole upload +# would have wasted bandwidth until the final MOVE. +STATUS=$(nc_req PUT \ + "/remote.php/dav/uploads/$username/$UPLOAD_ID_QUOTA/00001" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$FIXTURE_200B") +[[ "$STATUS" == "507" ]] || fail "PUT over-quota chunk: got $STATUS, expected 507" +pass "PUT over-quota chunk rejected (status=$STATUS)" + +# 3e. Abort the leftover session. +STATUS=$(nc_req DELETE "/remote.php/dav/uploads/$username/$UPLOAD_ID_QUOTA") +[[ "$STATUS" =~ ^(204|404)$ ]] || fail "DELETE quota session: got $STATUS" +pass "DELETE quota session (status=$STATUS)" + +# 3f. Restore admin's original envelope immediately — keeps the rest +# of the suite running under the right cap. The EXIT trap also +# restores it as belt-and-braces. +curl -s -X PUT \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"quota_bytes\":${ORIGINAL_ADMIN_QUOTA}}" \ + "$base_url/api/admin/users/$ADMIN_ID/quota" > /dev/null +pass "Admin envelope restored to ${ORIGINAL_ADMIN_QUOTA}" + # ── summary ────────────────────────────────────────────────────────────────── echo