From 78cb37b31195d1657a5273e9eedae60360f0d012 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 13 May 2026 11:33:45 +0200 Subject: [PATCH] feat: check thumbnail cleanup on files deletion + correct ref counter --- src/application/ports/mod.rs | 1 + src/application/services/trash_service.rs | 19 ++- src/common/di.rs | 6 +- .../pg/file_blob_write_repository.rs | 23 ++- src/infrastructure/services/dedup_service.rs | 136 +++++++++++++---- .../services/thumbnail_service.rs | 24 +++ src/interfaces/api/handlers/dedup_handler.rs | 23 ++- src/interfaces/api/handlers/file_handler.rs | 1 + tests/api/dedup_blob_cleanup.hurl | 44 ++++++ tests/api/run.sh | 5 + tests/api/storage_cleanup_check.sh | 137 ++++++++++++++++++ tests/common/internal_storage_helper.sh | 66 +++++++++ tests/common/server.env | 1 + tests/fixtures/blue-image.png | Bin 0 -> 4781 bytes tests/fixtures/green-image.png | Bin 0 -> 4773 bytes tests/fixtures/red-image.png | Bin 0 -> 4780 bytes 16 files changed, 444 insertions(+), 42 deletions(-) create mode 100755 tests/api/storage_cleanup_check.sh create mode 100755 tests/common/internal_storage_helper.sh create mode 100644 tests/fixtures/blue-image.png create mode 100644 tests/fixtures/green-image.png create mode 100644 tests/fixtures/red-image.png diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 22302921..2cd8e061 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -1,4 +1,5 @@ pub mod auth_ports; +pub mod blob_lifecycle; pub mod blob_storage_ports; pub mod cache_ports; pub mod calendar_ports; diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index a4ba03fd..fedb5262 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -16,6 +16,7 @@ use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlob use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository; +use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::file_content_cache::FileContentCache; use crate::infrastructure::services::thumbnail_service::ThumbnailService; @@ -45,6 +46,10 @@ pub struct TrashService { /// Port for folder operations (get folder, trash, restore, delete) folder_storage_port: Arc, + /// Dedup service — garbage-collected after bulk trash empty to clean up + /// orphaned blob files and thumbnails that the PG trigger cannot reach. + dedup_service: Arc, + /// Thumbnail service for cleaning up thumbnails on permanent delete thumbnail_service: Option>, @@ -62,6 +67,7 @@ impl TrashService { file_write_port: Arc, folder_storage_port: Arc, retention_days: u32, + dedup_service: Arc, thumbnail_service: Option>, content_cache: Option>, ) -> Self { @@ -70,6 +76,7 @@ impl TrashService { file_read_port, file_write_port, folder_storage_port, + dedup_service, thumbnail_service, content_cache, retention_days, @@ -713,18 +720,24 @@ impl TrashUseCase for TrashService { Vec::new() }; - // clear_trash() already performs bulk SQL DELETEs in 2 queries: + // clear_trash() performs bulk SQL DELETEs in 2 queries: // 1. DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE // 2. DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE // // Folder deletion cascades (FK ON DELETE CASCADE) to child folders and // their files. The PG trigger `trg_files_decrement_blob_ref` automatically - // decrements blob ref_counts for every deleted file row — no Rust-side - // remove_reference() call is needed. + // decrements blob ref_counts for every deleted file row. // // Finally it clears the trash_items index for the user. self.trash_repository.clear_trash(&user_id).await?; + // The PG trigger decremented ref_counts but cannot delete disk files or + // thumbnails. Run garbage_collect() to remove any blobs whose ref_count + // reached 0, along with their blob-keyed thumbnail files. + if let Err(e) = self.dedup_service.garbage_collect().await { + warn!("empty_trash: garbage_collect failed: {:?}", e); + } + // Invalidate content cache for all permanently deleted files. if let Some(cc) = &self.content_cache { for file_id in &trashed_file_ids { diff --git a/src/common/di.rs b/src/common/di.rs index 28a6a8e4..b6acc872 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -264,7 +264,7 @@ impl AppServiceFactory { db_pool.clone(), maintenance_pool.clone(), ) - .with_thumbnail_service(thumbnail_service.clone()), + .add_blob_hook(thumbnail_service.clone()), ); dedup_service.initialize().await?; @@ -451,10 +451,12 @@ impl AppServiceFactory { repos.file_write_repository.clone(), repos.folder_repository.clone(), self.config.storage.trash_retention_days, + core.dedup_service.clone(), Some(core.thumbnail_service.clone()), Some(core.file_content_cache.clone()), )); + // Initialize cleanup service (bulk-deletes expired items in 2 SQL queries) let cleanup_service = TrashCleanupService::new( trash_repo.clone(), @@ -1010,7 +1012,7 @@ impl CoreServices { tokio::spawn(async move { match ds.read_blob_bytes(&hash).await { Ok(bytes) => { - ts.generate_all_sizes_background_from_bytes(file_id, hash, bytes); + ts.generate_all_sizes_background_from_bytes(file_id, hash, bytes, ds.clone()); } Err(e) => { tracing::warn!( diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index 1562ea34..7e6c94ae 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -604,8 +604,27 @@ impl FileWritePort for FileBlobWriteRepository { } async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> { - // Same as delete_file — removes from DB and decrements blob ref - self.delete_file(file_id).await + // Read blob_hash before deletion so we can clean up disk after the + // PG trigger has decremented the ref_count. + let blob_hash: Option = sqlx::query_scalar( + "SELECT blob_hash FROM storage.files WHERE id = $1::uuid", + ) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("FileBlobWrite", format!("fetch blob_hash: {e}")) + })?; + + // DELETE fires trg_files_decrement_blob_ref → storage.blobs.ref_count-- + self.delete_file(file_id).await?; + + // If the blob is now unreferenced, remove disk file + thumbnails. + if let Some(hash) = blob_hash { + self.dedup.cleanup_if_orphaned(&hash).await; + } + + Ok(()) } async fn copy_folder_tree( diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index 99c07d36..378fcbf0 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -45,11 +45,11 @@ use tokio::fs; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use crate::application::ports::blob_storage_ports::BlobStorageBackend; +use crate::application::ports::blob_lifecycle::BlobDeletionHook; use crate::application::ports::dedup_ports::{ BlobMetadataDto, DedupPort, DedupResultDto, DedupStatsDto, }; use crate::domain::errors::{DomainError, ErrorKind}; -use crate::infrastructure::services::thumbnail_service::ThumbnailService; // ── CDC Constants ──────────────────────────────────────────────────────────── @@ -84,9 +84,8 @@ pub struct DedupService { /// Isolated maintenance pool for long-running operations /// (verify_integrity, garbage_collect) that must never starve the primary. maintenance_pool: Arc, - /// Optional thumbnail service — when set, blob-hash thumbnails are deleted - /// from disk whenever a blob's ref_count reaches zero. - thumbnail_service: Option>, + /// Hooks notified when a blob's ref_count reaches zero and it is deleted. + blob_hooks: Vec>, } impl DedupService { @@ -104,17 +103,24 @@ impl DedupService { backend, pool, maintenance_pool, - thumbnail_service: None, + blob_hooks: vec![], } } - /// Attach a thumbnail service so that disk thumbnails are cleaned up when - /// a blob's ref_count drops to zero. - pub fn with_thumbnail_service(mut self, svc: Arc) -> Self { - self.thumbnail_service = Some(svc); + /// Register a [`BlobDeletionHook`] to be called whenever a blob's + /// ref_count reaches zero. Hooks are called in registration order. + pub fn add_blob_hook(mut self, hook: Arc) -> Self { + self.blob_hooks.push(hook); self } + /// Fire all registered hooks for a deleted blob. + async fn fire_blob_hooks(&self, hash: &str) { + for hook in &self.blob_hooks { + hook.on_blob_deleted(hash).await; + } + } + /// Creates a stub instance for testing — never hits PG or the filesystem. #[cfg(any(test, feature = "integration_tests"))] pub fn new_stub() -> Self { @@ -129,7 +135,7 @@ impl DedupService { backend: Arc::new(LocalBlobBackend::new(Path::new("/tmp/oxicloud_stub_blobs"))), pool: stub_pool.clone(), maintenance_pool: stub_pool, - thumbnail_service: None, + blob_hooks: vec![], } } @@ -600,7 +606,7 @@ impl DedupService { /// references the blob identified by `hash`. pub async fn user_owns_blob_reference(&self, hash: &str, user_id: &str) -> bool { sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM storage.files WHERE blob_hash = $1 AND user_id = $2 AND NOT is_trashed)", + "SELECT EXISTS(SELECT 1 FROM storage.files WHERE blob_hash = $1 AND user_id = $2::uuid AND NOT is_trashed)", ) .bind(hash) .bind(user_id) @@ -785,10 +791,8 @@ impl DedupService { } } - // Bug 4 fix: delete disk thumbnails keyed by file_hash (last reference gone) - if let Some(ts) = &self.thumbnail_service { - ts.delete_blob_thumbnails(file_hash).await; - } + // Bug 4 fix: notify hooks — e.g. thumbnail cleanup keyed by file_hash + self.fire_blob_hooks(file_hash).await; tracing::info!( "MANIFEST DELETED: {} ({} chunks, {} orphan chunks removed)", @@ -867,10 +871,8 @@ impl DedupService { tracing::warn!("Failed to delete blob file {}: {}", hash, e); } - // Bug 3 fix: delete disk thumbnails keyed by hash (last reference gone) - if let Some(ts) = &self.thumbnail_service { - ts.delete_blob_thumbnails(hash).await; - } + // Bug 3 fix: notify hooks — e.g. thumbnail cleanup keyed by hash + self.fire_blob_hooks(hash).await; tracing::info!("BLOB DELETED: {} (no more references)", &hash[..12]); Ok(true) @@ -897,6 +899,91 @@ impl DedupService { } } + /// Targeted cleanup for a single blob after the PG trigger has already + /// decremented its ref_count. Deletes the blob row, disk file, and + /// blob-keyed thumbnails if ref_count has reached 0. + /// + /// Handles both the legacy whole-file blob path (storage.blobs) and the + /// CDC manifest path (storage.chunk_manifests). Best-effort: logs + /// warnings on failure rather than returning an error. + pub async fn cleanup_if_orphaned(&self, hash: &str) { + let short = &hash[..hash.len().min(12)]; + + // ── CDC manifest path (must run FIRST) ─────────────────── + // For single-chunk CDC files file_hash == chunk_hash, so the PG + // trigger on storage.files already decremented storage.blobs.ref_count + // when this function is called. try_dedup_hit increments + // chunk_manifests.ref_count but NOT storage.blobs.ref_count, so + // blobs.ref_count can reach 0 while the manifest still has ref_count > 1 + // (other files sharing the same blob). Checking the manifest first + // prevents premature blob + manifest deletion. + let manifest = sqlx::query_as::<_, (i32, Vec)>( + "SELECT ref_count, chunk_hashes \ + FROM storage.chunk_manifests WHERE file_hash = $1", + ) + .bind(hash) + .fetch_optional(self.pool.as_ref()) + .await + .unwrap_or(None); + + if let Some((ref_count, chunk_hashes)) = manifest { + if ref_count <= 1 { + // Last reference — remove manifest and all its chunks. + if let Err(e) = self + .remove_manifest_reference(hash, ref_count, &chunk_hashes) + .await + { + tracing::warn!("cleanup_if_orphaned: manifest cleanup failed for {short}: {e}"); + } + } else { + // Other files still share this blob: just decrement the manifest + // counter and undo the PG trigger's premature chunk ref_count + // decrement (blobs.ref_count is chunk-level; the manifest is the + // authoritative file-level counter). + sqlx::query( + "UPDATE storage.chunk_manifests \ + SET ref_count = ref_count - 1 WHERE file_hash = $1", + ) + .bind(hash) + .execute(self.pool.as_ref()) + .await + .ok(); + // Undo the PG trigger's decrement of storage.blobs.ref_count. + // The trigger fired with blob_hash = file_hash, so only the row + // WHERE hash = file_hash is affected. For single-chunk files + // file_hash == chunk_hash and that row exists; for multi-chunk + // files file_hash is not in storage.blobs, making this a no-op. + sqlx::query("UPDATE storage.blobs SET ref_count = ref_count + 1 WHERE hash = $1") + .bind(hash) + .execute(self.pool.as_ref()) + .await + .ok(); + tracing::debug!( + "cleanup_if_orphaned: manifest {short} ref_count {ref_count}→{}", + ref_count - 1 + ); + } + return; + } + + // ── Legacy blob path (no manifest) ─────────────────────── + let deleted_blob = sqlx::query_scalar::<_, String>( + "DELETE FROM storage.blobs WHERE hash = $1 AND ref_count <= 0 RETURNING hash", + ) + .bind(hash) + .fetch_optional(self.pool.as_ref()) + .await + .unwrap_or(None); + + if deleted_blob.is_some() { + if let Err(e) = self.backend.delete_blob(hash).await { + tracing::warn!("cleanup_if_orphaned: disk delete failed for {short}: {e}"); + } + self.fire_blob_hooks(hash).await; + tracing::info!("cleanup_if_orphaned: removed orphaned blob {short}"); + } + } + // ── Read operations ────────────────────────────────────────── /// Stream blob content — CDC-aware with legacy fallback. @@ -1347,16 +1434,7 @@ impl DedupService { if let Err(e) = self.backend.delete_blob(hash).await { tracing::warn!("Failed to delete orphan blob {hash}: {e}"); } - // Clean up thumbnails (best-effort, only local backends) - if let Some(blob_path) = self.backend.local_blob_path(hash) - && let Some(storage_root) = blob_path.ancestors().nth(3) - { - let thumbnails_root = storage_root.join(".thumbnails"); - for dir in &["icon", "preview", "large"] { - let thumb = thumbnails_root.join(dir).join(format!("{hash}.jpg")); - let _ = fs::remove_file(&thumb).await; - } - } + self.fire_blob_hooks(hash).await; total_bytes += *size as u64; } total_deleted += batch.len() as u64; diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index 7c3622f1..814df6d6 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -25,6 +25,7 @@ use tokio::time::timeout; use crate::application::ports::thumbnail_ports::{ ThumbnailPort, ThumbnailSize as PortThumbnailSize, ThumbnailStatsDto, }; +use crate::infrastructure::services::dedup_service::DedupService; use crate::domain::errors::{DomainError, ErrorKind}; /// Thumbnail sizes supported by the system @@ -831,10 +832,22 @@ impl ThumbnailService { file_id: String, blob_hash: String, original_data: Bytes, + dedup: Arc, ) { tokio::spawn(async move { tracing::info!("🖼️ Background thumbnail generation starting: {}", file_id); + // Guard: if the blob was deleted before this task ran, cleanup_if_orphaned + // already fired with no thumbnails on disk — writing them now would leak them. + // Use the DB check (manifest + blobs tables) as the authoritative source. + if !dedup.blob_exists(&blob_hash).await { + tracing::debug!( + "Blob {}… deleted before thumbnail task ran, skipping", + &blob_hash[..blob_hash.len().min(12)] + ); + return; + } + let all_exist = { let mut ok = true; for size in ThumbnailSize::all() { @@ -971,6 +984,17 @@ impl ThumbnailService { } } +// ─── BlobDeletionHook ──────────────────────────────────────────────────────── + +impl crate::application::ports::blob_lifecycle::BlobDeletionHook for ThumbnailService { + fn on_blob_deleted<'a>( + &'a self, + blob_hash: &'a str, + ) -> std::pin::Pin + Send + 'a>> { + Box::pin(async move { self.delete_blob_thumbnails(blob_hash).await }) + } +} + // ─── Port implementation ───────────────────────────────────────────────────── /// Convert port ThumbnailSize to infra ThumbnailSize. diff --git a/src/interfaces/api/handlers/dedup_handler.rs b/src/interfaces/api/handlers/dedup_handler.rs index 9ed5b746..3cab36ba 100644 --- a/src/interfaces/api/handlers/dedup_handler.rs +++ b/src/interfaces/api/handlers/dedup_handler.rs @@ -26,7 +26,9 @@ pub struct HashCheckResponse { /// If exists, the size of the existing blob #[serde(skip_serializing_if = "Option::is_none")] pub existing_size: Option, - /// If exists, the number of references to this blob + /// Global reference count for this blob across all users. + /// Only populated when the authenticated user has the `admin` role; + /// omitted for regular users to prevent cross-user content inference. #[serde(skip_serializing_if = "Option::is_none")] pub ref_count: Option, } @@ -85,7 +87,8 @@ impl DedupHandler { /// Check if the authenticated user already has a file with the given hash. /// /// User-scoped: only reveals whether **this user** owns a file that - /// references the blob — never exposes global existence or ref_count. + /// references the blob — never exposes global existence to non-admins. + /// Admins additionally receive the global `ref_count` in the response. /// /// GET /api/dedup/check/{hash} pub(super) async fn check_hash_impl( @@ -113,13 +116,20 @@ impl DedupHandler { .await; if user_has_it { - // Fetch size from metadata (safe — user owns a reference) - let size = dedup.get_blob_metadata(&hash).await.map(|m| m.size); + // Fetch size from metadata (safe — user owns a reference). + // Admins also get the global ref_count for dedup accounting tests. + let metadata = dedup.get_blob_metadata(&hash).await; + let size = metadata.as_ref().map(|m| m.size); + let ref_count = if auth_user.role == "admin" { + metadata.map(|m| m.ref_count) + } else { + None // Never expose global ref_count to regular users + }; let response = HashCheckResponse { exists: true, hash, existing_size: size, - ref_count: None, // Never expose global ref_count + ref_count, }; Response::builder() .status(StatusCode::OK) @@ -492,6 +502,7 @@ impl DedupHandler { .unwrap() .into_response() } + } // ── Route handlers (free functions) ────────────────────────────────────────── @@ -511,7 +522,7 @@ impl DedupHandler { ("hash" = String, Path, description = "BLAKE3 hash (64 hex characters)"), ), responses( - (status = 200, description = "Hash check result (user-scoped)", body = HashCheckResponse), + (status = 200, description = "Hash check result. `ref_count` is only present for admin users.", body = HashCheckResponse), (status = 400, description = "Invalid hash format"), ), tag = "dedup", diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 8ff0c570..e543047e 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -783,6 +783,7 @@ impl FileHandler { file_id, blob_hash_owned, original_bytes, + dedup_service.clone(), ); } Err(err) => { diff --git a/tests/api/dedup_blob_cleanup.hurl b/tests/api/dedup_blob_cleanup.hurl index 57a533af..17c6424e 100644 --- a/tests/api/dedup_blob_cleanup.hurl +++ b/tests/api/dedup_blob_cleanup.hurl @@ -20,6 +20,11 @@ # server uses the legacy blob path — so we avoid stats-based # assertions and rely on observable thumbnail behaviour instead. # +# BLAKE3 hash of fixtures/dedup-test.jpg (= dedup-test-2.jpg content): +# cde1ca663a2e62e0dadb41c3194e11ecb7d971d84c7451db17063b55c09e8066 +# Used in /api/dedup/check/{hash} calls below to track ref_count lifecycle. +# ref_count is only returned for admin users; setup.hurl creates an admin. +# # Prerequisites: setup.hurl must have run (admin user exists). # # Run: @@ -87,6 +92,16 @@ jsonpath "$.name" == "dedup-test.jpg" jsonpath "$.folder_id" == {{test_folder_id}} +# ref_count == 1: blob has exactly one file reference after first upload +GET {{base_url}}/api/dedup/check/cde1ca663a2e62e0dadb41c3194e11ecb7d971d84c7451db17063b55c09e8066 +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.exists" == true +jsonpath "$.ref_count" == 1 + + # ───────────────────────────────────────────────────────────── # Step 4 – Upload identical content again as dedup-test-2.jpg # Dedup: same blob, new file record, different file ID @@ -105,6 +120,16 @@ jsonpath "$.name" == "dedup-test-2.jpg" jsonpath "$.id" != "{{file1_id}}" +# ref_count == 2: dedup hit — same blob now referenced by two file records +GET {{base_url}}/api/dedup/check/cde1ca663a2e62e0dadb41c3194e11ecb7d971d84c7451db17063b55c09e8066 +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.exists" == true +jsonpath "$.ref_count" == 2 + + # ───────────────────────────────────────────────────────────── # Step 5 – Dedup proof: thumbnails are byte-identical # Thumbnail generation reads blob bytes and is keyed by @@ -156,6 +181,16 @@ Authorization: Bearer {{token}} HTTP 200 +# ref_count == 1: blob survives — file2 still holds a reference +GET {{base_url}}/api/dedup/check/cde1ca663a2e62e0dadb41c3194e11ecb7d971d84c7451db17063b55c09e8066 +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.exists" == true +jsonpath "$.ref_count" == 1 + + # ───────────────────────────────────────────────────────────── # Step 8 – Blob still alive: file 2 thumbnail is accessible # After file 1 is permanently deleted the blob ref_count @@ -201,6 +236,15 @@ Authorization: Bearer {{token}} HTTP 200 +# ref_count hits 0 → blob and manifest deleted; user no longer owns this hash +GET {{base_url}}/api/dedup/check/cde1ca663a2e62e0dadb41c3194e11ecb7d971d84c7451db17063b55c09e8066 +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.exists" == false + + # ───────────────────────────────────────────────────────────── # Step 11 – Cleanup: delete the (now empty) test folder # ───────────────────────────────────────────────────────────── diff --git a/tests/api/run.sh b/tests/api/run.sh index fa12b7af..5f660b71 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -62,6 +62,9 @@ OXICLOUD_SERVER_PORT=$SERVER_PORT OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/api/storage" set +a +# ensure storage is empty before starting +echo "Wipe $OXICLOUD_STORAGE_PATH to ensure clean startup" +rm -rf "$OXICLOUD_STORAGE_PATH" mkdir -p "$OXICLOUD_STORAGE_PATH" # ── 3. Start OxiCloud server ────────────────────────────────────────────────── @@ -97,4 +100,6 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test #bash "$API_DIR/dedup_bulk_upload.sh" +bash "$API_DIR/storage_cleanup_check.sh" + log "All tests passed." diff --git a/tests/api/storage_cleanup_check.sh b/tests/api/storage_cleanup_check.sh new file mode 100755 index 00000000..1a421cac --- /dev/null +++ b/tests/api/storage_cleanup_check.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# ============================================================= +# OxiCloud – Storage disk-cleanup verification +# ============================================================= +# 1. Moves every live file and folder to trash via the REST API. +# 2. Calls DELETE /api/trash/empty to permanently delete all +# remaining trash items (including any left by previous tests). +# 3. Asserts that no regular files remain under +# $OXICLOUD_STORAGE_PATH/.thumbnails or .blobs. +# +# Called by run.sh after all Hurl tests have passed. +# Can also be run standalone (server must already be up): +# bash tests/api/storage_cleanup_check.sh +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +STORAGE_PATH="${OXICLOUD_STORAGE_PATH:-$REPO_ROOT/tests/api/storage}" + +# shellcheck source=test.env +source "$SCRIPT_DIR/test.env" + +log() { echo "[storage-check] $*"; } +fail() { echo $'\e[31m'"[storage-check] FAIL: $*"$'\e[0m' >&2; exit 1; } + +# ── 1. Login ────────────────────────────────────────────────────────────────── + +TOKEN=$(curl -sf -X POST "$base_url/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"$username\",\"password\":\"$password\"}" \ + | jq -r '.access_token') + +[[ -z "$TOKEN" || "$TOKEN" == "null" ]] && fail "login failed" +log "Logged in." + +AUTH="Authorization: Bearer $TOKEN" + +# ── 1b. Upload a probe image and verify its blob + thumbnail exist on disk ───── + +# shellcheck source=../common/internal_storage_helper.sh +source "$REPO_ROOT/tests/common/internal_storage_helper.sh" + +FIXTURE="$REPO_ROOT/tests/fixtures/blue-image.png" + +HOME_FOLDER_ID=$(curl -sf -H "$AUTH" "$base_url/api/folders" | jq -r '.[0].id') +[[ -z "$HOME_FOLDER_ID" || "$HOME_FOLDER_ID" == "null" ]] && fail "could not get home folder id" + +PROBE_FILE_ID=$(curl -sf -X POST -H "$AUTH" \ + -F "folder_id=$HOME_FOLDER_ID" \ + -F "file=@$FIXTURE;type=image/png" \ + "$base_url/api/files/upload" | jq -r '.id') +[[ -z "$PROBE_FILE_ID" || "$PROBE_FILE_ID" == "null" ]] && fail "probe file upload failed" +log "Probe file uploaded (id=$PROBE_FILE_ID)." + +# GET thumbnail to trigger on-demand generation +HTTP_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -H "$AUTH" \ + "$base_url/api/files/$PROBE_FILE_ID/thumbnail/icon") +[[ "$HTTP_STATUS" != "200" ]] && fail "thumbnail GET returned HTTP $HTTP_STATUS (expected 200)" +log "Thumbnail fetched (HTTP 200)." + +assert_local_blob_existsy "$FIXTURE" "$STORAGE_PATH" || fail "probe blob not found on disk" +assert_preview_existsy "$FIXTURE" "$STORAGE_PATH" || fail "probe thumbnail not found on disk" +log "Probe blob and thumbnail confirmed present on disk." + +# ── 2. Move all live files and folders to trash ─────────────────────────────── +# +# For each root folder, list its direct children and soft-delete them. +# The server cascades folder deletion to all nested contents, so we only +# need to iterate one level deep. + +ROOT_FOLDERS=$(curl -sf -H "$AUTH" "$base_url/api/folders" | jq -r '.[].id') + +for folder_id in $ROOT_FOLDERS; do + CONTENTS=$(curl -sf -H "$AUTH" "$base_url/api/folders/$folder_id/listing") + + while IFS= read -r sub_id; do + [[ -z "$sub_id" ]] && continue + curl -sf -X DELETE -H "$AUTH" "$base_url/api/folders/$sub_id" >/dev/null + done < <(echo "$CONTENTS" | jq -r '.folders[].id') + + while IFS= read -r file_id; do + [[ -z "$file_id" ]] && continue + curl -sf -X DELETE -H "$AUTH" "$base_url/api/files/$file_id" >/dev/null + done < <(echo "$CONTENTS" | jq -r '.files[].id') +done + +log "All live objects moved to trash." + +# ── 2b. Verify all root folders are empty according to the API ──────────────── + +for folder_id in $ROOT_FOLDERS; do + CONTENTS=$(curl -sf -H "$AUTH" "$base_url/api/folders/$folder_id/listing") + SUB_COUNT=$(echo "$CONTENTS" | jq '.folders | length') + FILE_COUNT=$(echo "$CONTENTS" | jq '.files | length') + if [[ "$SUB_COUNT" -ne 0 || "$FILE_COUNT" -ne 0 ]]; then + fail "folder $folder_id still has $SUB_COUNT subfolder(s) and $FILE_COUNT file(s)" + fi +done + +log "API confirms all root folders are empty." + +# ── 3. Permanently delete everything in trash ───────────────────────────────── + +curl -sf -X DELETE -H "$AUTH" "$base_url/api/trash/empty" >/dev/null +log "Trash emptied." + +# ── 3b. Verify trash is empty according to the API ─────────────────────────── + +TRASH_COUNT=$(curl -sf -H "$AUTH" "$base_url/api/trash" | jq 'length') +if [[ "$TRASH_COUNT" -ne 0 ]]; then + fail "trash still contains $TRASH_COUNT item(s) after empty" +fi + +log "API confirms trash is empty." + +# ── 4. Disk verification ────────────────────────────────────────────────────── + +THUMB_FILES=$(find "$STORAGE_PATH/.thumbnails" -type f 2>/dev/null || true) +BLOB_FILES=$(find "$STORAGE_PATH/.blobs" -type f 2>/dev/null || true) + +if [[ -n "$THUMB_FILES" ]]; then + THUMB_COUNT=$(echo "$THUMB_FILES" | wc -l | tr -d ' ') + log "Leftover thumbnail files ($THUMB_COUNT):" + echo "$THUMB_FILES" + fail "$THUMB_COUNT thumbnail file(s) remain on disk after full cleanup" +fi + +if [[ -n "$BLOB_FILES" ]]; then + BLOB_COUNT=$(echo "$BLOB_FILES" | wc -l | tr -d ' ') + log "Leftover blob files ($BLOB_COUNT):" + echo "$BLOB_FILES" + fail "$BLOB_COUNT blob file(s) remain on disk after full cleanup" +fi + +log "OK — no blobs or thumbnails remain on disk." diff --git a/tests/common/internal_storage_helper.sh b/tests/common/internal_storage_helper.sh new file mode 100755 index 00000000..0fe70822 --- /dev/null +++ b/tests/common/internal_storage_helper.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +if ! which b3sum >/dev/null 2>/dev/null +then + echo "please install b3sum (brew install b3sum on Mac, apt installb3sum on Debian, etc)" >&2 + exit 1 +fi + +HASH="" +FILE_CACHE="" + +# return the hash of a file (stores into HASH variable) +oxi_hash() { + if [[ -z "$HASH" || "$FILE_CACHE" != "$1" ]] + then + HASH=$(b3sum --no-names "$1") + FILE_CACHE="$1" + fi + echo "$HASH" +} + +# returns the local blob localisation +local_blob_path() { + local BLOB_PREFIX + oxi_hash "$1" >/dev/null + BLOB_PREFIX=${HASH:0:2} + echo ".blobs/$BLOB_PREFIX/$HASH.blob" +} + +# returns the preview localisation without it's extension +preview_path() { + local SIZE + oxi_hash "$1" >/dev/null + # default size: icon + SIZE="${3:-icon}" + echo ".thumbnails/$SIZE/$HASH" +} + +assert_local_blob_existsy() { + BLOB_PATH=$(local_blob_path "$1") + STORAGE="$2" + if [[ -e $STORAGE/$BLOB_PATH ]] + then + echo "$BLOB_PATH exists" + return 0 + else + echo $'\e[31m'"$BLOB_PATH does not exist"$'\e[0m' >&2 + return 1 + fi +} + +assert_preview_existsy() { + THUMBNAIL_PATH=$(preview_path "$1") + STORAGE="$2" + if [[ -e "$STORAGE/$THUMBNAIL_PATH.jpg" || -e "$STORAGE/$THUMBNAIL_PATH.webp" ]] + then + echo "thumbnail $THUMBNAIL_PATH.(jpg|webp) exists" + return 0 + else + echo $'\e[31m'"thumbnail $THUMBNAIL_PATH.(jpg|webp) does not exist"$'\e[0m' >&2 + echo $STORAGE + find $STORAGE/.thumbnails + return 1 + fi +} + diff --git a/tests/common/server.env b/tests/common/server.env index 65e1a78b..f60ccef3 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -16,3 +16,4 @@ OXICLOUD_EXPOSE_SYSTEM_USERS=true OXICLOUD_WOPI_ENABLED=false OXICLOUD_OIDC_ENABLED=false RUST_LOG=warn +#RUST_LOG=debug diff --git a/tests/fixtures/blue-image.png b/tests/fixtures/blue-image.png new file mode 100644 index 0000000000000000000000000000000000000000..ac6fca154ff11f64c68584f7a0044d9bd7a8deac GIT binary patch literal 4781 zcmZ`-d0dU@9)Hj49Gy<3I;X`JZ~JLc2_;j!(I(_1R3vAnY_}#UD$8)*YZ)y#h)T)1 zmSHSeGNpwM6Cz84QK=M~M3xH4jOBix^T)k+X1ag)e4d{3{I>7!`&(YAvLJsw(O?lp zQF@aDd}dG-Pf7j>`{PDY;dXmrpQ@x|jU(BwH zk33)3t(zK`ckJjNw;LKNzWlz8Yvxt-TdVbPz|c=-y}``V&;AOENAm~hNR>ZFM=~w} z>XH03{PCDKXCHnDrbSkP4G4J*GtmQxx}YSp}P^pi_6aXR%-N zj!Am$RP0*&-fjP@kj3+Tw5av)rRQI6SVEv5PjH9v31x8H09l#)PKuNA617B9C6~R` zf>o%4Xi@axvrW68rk}v5ioIdBHNhVqd^C|U71kf85r@C$8&#P*=}Q=v31pfh1UsB1 zLkt;K1(kM&JFT@180B=9Ypt&IOXTMTD%5hGz;)ogWqhGQoiEG1*l~8Xm|^9o)YSsl zl!7Nh1Bjl3=rGkgh#FX{*!`_Htvk;sw6t4|WQ<4RX=8wN2e`a73|GRwhagF+%S!9{S*jrGGI=uKY=fDEdj~yuElHiliHRy(XE9;t9?y z>-8vz)1moCL7k)5>T~hpA}}3GJ<;SaenkOYV&6`Jh=q%8dJliXef%tzi!grs`2rmm zBnQ-IJ~%5#>ROH~Xb*>{UgWitK^T);T{a%h_E8EP?I#&%u44V9&sP^?=4sc zuP2*qUI9TFC_Z!Y^?W2}v-sSoP_B{d8cBTdLM73{w_*x`8WLtg+f#lxwo0g4v;iLaxV8fOCZ^g7&6OC`1ZoO?P^qWH+3}>mYDeY zAZu}#p2iT6(2p-z_Zy#hX)I&DD_k)O$Ei4mtTYQEeWT5KA*y?rg@dhj4U( zmK*K`9vAOJX9@_KPkq<}xA<`=z6Pi91g}gsr$lsc>P!>zhZDea&~G3WoPi3K?DCy& z+&Pp-#~{AJi$aL@AZSm&w-Luvel4rH<2f-R;$$B&UC`;>z}lgn?VYMYGiIBDzP5AY zG)T$BP}~6e_DftQ1gWEGz7y(q%9=75^~*FH&+XIC&g8aigi6G*CI%vdIggTT@kyh@I^Q?sP*Ol5zMFKngUpEwCL_E z5!apDFse6vGHW$b3hNUKO`Td3_GKF@6 z16E;Ilf?0+AP|t4Yd;?Smf{X$BH3-%(x~69PNPf4)Gi_%mPN3fk$%nZIB!PX26wlK z!_4LVxl}-BQE&jR)r7O$@}Sx#Ja!3YInT;>)`UxqsKoctYl28+>4VpkhRxRm6}J}j zoM1VGkm(Q#bWl1Wxe_FAz1`lp=^?4-Xw0TTbzQL8jy_dt(-BD`ibdVVN`6Ww-44BJ zP{*^;uoH{ETa;RQ%lF$r1*-@2EN$l!-FuanIJIv}PTRXONMyODzw;{}rv1wq+7^ttKr zPd_#7r8pT+AilnIEL1#4M=q_eCp-_PE86Up^L0OiY!r6@SwE8fR70>Vkm_=aJ{=>N zYFXoroCf@2M$iN4>6$HM2x)P91b4h2lG{FLal}p@L$3qBhKnYEqzbhmVDYIg)WG5o zSpF6EX1yK$YDW(%wR$!T$1q0~YqEZbzoz>g0817JKgH4_mw?EVYYHaA#a|Hf*~QIo z8Z+SiKB%`H@I~W$D4rjikx#aTO!iKR$ZCnpeX@KcfnQ$ce?SfMBRDWP<0n7S*mQ~; z1Vx*UjzX-bIE)b{IutRfb-7Sc^p%K3$a-IUON%k@wm|^A6n`Hz22e+%2j$#7Jp`bN zbw)cYE4ybC2mZeBs7e$a?=T`hjY^sXxvAJmLc+|*PVyPW9&@FKxS9Z0J(&*TDoXm= zo{aOP%3R+2qSh@kuL5Vbr4VU(@o2sV#r+C;rQ!9clR7CoD9#I54D%=>#kL7e^3lNo zBuAO*2UrhI6fc3mVwA=MpJx^XXa^O0e^u`8x#}doa42Sx)+tXo!=s0T0Y6`wPSnS< z^2OZH?2cggYtYBSqiY;Lkb4Id&zGbU?dDkLD%-_GyR(XYc=Hp|#iO;57H^krAhbeV z27i+~u}xv$*^P$1;6Ndv<2UF>$19!@D{$+&-}}|e*5N(l$8weDatTJ=SOwN>aMHx{ z3LD(33f?QSX77ZzR(Z8>_7rs2e{yHKKQELpvN@O|TW@zm<7U*;vK||vE>K<-A#mOB zi#e|Au=~EqDj9-%`DT>grFLZ#zcGvAiroc|i;3U-AXHIEY$WnRaoO}EP2>6vo_-L{ zBfpPC+O9OsB%z20qCPhTkK2sv)sQ;hoirDi>B6*@otH-NH5_faxDS?@cp1wL<-bAT z5M78j_2Sd|u$P9d&%!%=CzRA<6cqWiy?lc4B-8~^lRV7);Kp}I@%ZpEJ0d#mslD=t zi=`%zT!?hnU%Ev;HbT(+N7qHxCJezKLX~IySeFWg0x0B^#{-~4%J~C8OS5LMOrf5K zn#{UlO8n}?RPwyjWyT$&AKKQ0VMGn6{T25vA=o$Wt#E^(!8%sX$}8Ok#Fsw4a1wYO zhADs|U;9HojPm2)K(6C>(2(L50f3yE`q$^lV&8^zu2dJ0V>tM;`*@-@;d%0Q1s%@+ zRu`hBfH5w$l;C25qi@ZwD|O^^=OjQ1&GghPkj+5DT|68`f$i^pgkXk)a|7x0TTtba zjlOVQh86$b_3EN+0&H~ZiD)7Q4N^e59FuGBo#hyLLPw+CnT*cU!M-G?myzBsO7j6H zMYFVC>cmZ_KYOe4(TTHiwxCrgC$ikZ%@+yDkJ#26VxM1Y$nIOviGFqZ1UTuo$`pxF zj{(p(Z#NL?&1Sg`{!0;=@E*2?F^Pp^aJ~f7DSQ(Wi@GreVC~sgOj^@lRV;m7+>}po z_)4m}-&N)b$y><&o1}-DVrSFP_k4ACljh}x+2=fpFr3*4L=4wk&(_KISEyIOlcpMN z;t7_I*g<7e@T8?wT|$?PuO-)gHS@8yEhFe9V9_3_BiYU8AcM0bi^zI8n0qO*+ET8; z`|ry^U66WX*`FjIi+UctAVf0@&*N)3?o2QFn&jt0 z$D)ZB042rc0v?-m{i~v}TPSWTJc&B`$_MuXaEW)u5NbAyvpqup%UY@Y1kX-Xyk~3IpVuI=-}?g4?W*Hsti__8%(dvj*2ZE^&agUS8MBJ) zY7THlv|tpp)IiH<-CuW+=;~q$=z52x#lTBg%uYY8s-NYd@bCi2CS zg*OW(YLSgh9)}Gs^_@Fe+83Y{>f0UBH@PrWVaM>VnwFzjA~2;)JIl#9*bBj1&&s|c zeQ*TU7n6~td^md_mGG|320cIp#xF5^vNkI|isnCtzBT>bNKH%zw61Q1@JwJ!Q~~|r zRHp=jeNMpH!fbC9ddU*Wp>$v*M>K1$Fj4-`e*;?AOkw~4 literal 0 HcmV?d00001 diff --git a/tests/fixtures/green-image.png b/tests/fixtures/green-image.png new file mode 100644 index 0000000000000000000000000000000000000000..e689f584a4eb34847051c8f71943f0642ed97a4b GIT binary patch literal 4773 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 ke;+eyk|Pd7DYAwLT+5^p?|pyo@4b9GRq3~I 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 literal 0 HcmV?d00001