feat: storage, thumbnails, image inspection, and ZIP extraction #3

Merged
bjoern merged 4 commits from feat/storage-and-thumbnails into main 2026-06-28 10:46:50 +02:00
Member

Summary

Implements the storage, thumbnail, image inspection, and ZIP extraction infrastructure layer — no REST endpoints yet.

What's included

ApplicationCore — 5 new ports

Port Purpose
IImageInspector Decode image metadata (dimensions, mediaType, extension, SHA-256)
IImageStorage Save/read/delete/exists for UUID-sharded image files
IThumbnailGenerator Generate WebP thumbnails from an image stream
IThumbnailStorage Save/read/delete/exists for WebP thumbnails
IZipExtractor Extract images from ZIP, sorted by path, ignoring non-images

Infrastructure — 5 implementations

Implementation Key Details
SkiaSharpImageInspector Decodes via SkiaSharp, format detection from magic bytes (JPEG/PNG/GIF/WebP/BMP), SHA-256 hashing
SkiaSharpThumbnailGenerator Mitchell cubic resize, preserves aspect ratio, WebP encode at quality 80, no upscaling
FilesystemImageStorage {imageDir}/{shard}/{uuid}.ext with auto-mkdir
FilesystemThumbnailStorage {thumbnailDir}/{shard}/{uuid}.webp
ZipExtractor ZipArchive-based, IAsyncEnumerable, [EnumeratorCancellation], sorts by path, ignores non-images

SkiaSharp package strategy (from OpenRouter.Net)

Following the proven pattern from TeamAI/OpenRouter.Net:

  • Infrastructure: SkiaSharp (managed wrapper only)
  • Server: SkiaSharp.NativeAssets.Linux.NoDependencies (provides libSkiaSharp.so)
  • Infrastructure.Tests: SkiaSharp.NativeAssets.Linux.NoDependencies (so tests run on Linux)
  • All pinned at 3.119.4 via CPM

Server DI registration

All services registered as singletons with env-var configurable paths:

  • DOUJIN_MANAGER_IMAGE_DIR (default: /app/data/images)
  • DOUJIN_MANAGER_THUMBNAIL_DIR (default: /app/data/thumbnails)

Tests (24 new, 47 total, all passing)

Test Suite Tests Coverage
FilesystemImageStorageTests 5 Save with sharded path, read, exists, delete, missing file
FilesystemThumbnailStorageTests 4 Save WebP, exists, read, delete
SkiaSharpImageInspectorTests 5 JPEG/PNG dimensions + mediaType, SHA-256, different hashes, invalid data
SkiaSharpThumbnailGeneratorTests 5 WebP output, aspect ratio preservation, no upscale, square images, invalid data
ZipExtractorTests 5 Sorted extraction, ignore non-images, file content, empty ZIP, nested directories

Verification

Check Result
dotnet build 0 errors, 0 warnings
dotnet test 47/47 pass
git diff --check Clean
## Summary Implements the storage, thumbnail, image inspection, and ZIP extraction infrastructure layer — no REST endpoints yet. ## What's included ### ApplicationCore — 5 new ports | Port | Purpose | |------|---------| | `IImageInspector` | Decode image metadata (dimensions, mediaType, extension, SHA-256) | | `IImageStorage` | Save/read/delete/exists for UUID-sharded image files | | `IThumbnailGenerator` | Generate WebP thumbnails from an image stream | | `IThumbnailStorage` | Save/read/delete/exists for WebP thumbnails | | `IZipExtractor` | Extract images from ZIP, sorted by path, ignoring non-images | ### Infrastructure — 5 implementations | Implementation | Key Details | |---------------|-------------| | `SkiaSharpImageInspector` | Decodes via SkiaSharp, format detection from magic bytes (JPEG/PNG/GIF/WebP/BMP), SHA-256 hashing | | `SkiaSharpThumbnailGenerator` | Mitchell cubic resize, preserves aspect ratio, WebP encode at quality 80, no upscaling | | `FilesystemImageStorage` | `{imageDir}/{shard}/{uuid}.ext` with auto-mkdir | | `FilesystemThumbnailStorage` | `{thumbnailDir}/{shard}/{uuid}.webp` | | `ZipExtractor` | `ZipArchive`-based, `IAsyncEnumerable`, `[EnumeratorCancellation]`, sorts by path, ignores non-images | ### SkiaSharp package strategy (from OpenRouter.Net) Following the proven pattern from `TeamAI/OpenRouter.Net`: - **Infrastructure**: `SkiaSharp` (managed wrapper only) - **Server**: `SkiaSharp.NativeAssets.Linux.NoDependencies` (provides `libSkiaSharp.so`) - **Infrastructure.Tests**: `SkiaSharp.NativeAssets.Linux.NoDependencies` (so tests run on Linux) - All pinned at **3.119.4** via CPM ### Server DI registration All services registered as singletons with env-var configurable paths: - `DOUJIN_MANAGER_IMAGE_DIR` (default: `/app/data/images`) - `DOUJIN_MANAGER_THUMBNAIL_DIR` (default: `/app/data/thumbnails`) ### Tests (24 new, 47 total, all passing) | Test Suite | Tests | Coverage | |-----------|-------|----------| | `FilesystemImageStorageTests` | 5 | Save with sharded path, read, exists, delete, missing file | | `FilesystemThumbnailStorageTests` | 4 | Save WebP, exists, read, delete | | `SkiaSharpImageInspectorTests` | 5 | JPEG/PNG dimensions + mediaType, SHA-256, different hashes, invalid data | | `SkiaSharpThumbnailGeneratorTests` | 5 | WebP output, aspect ratio preservation, no upscale, square images, invalid data | | `ZipExtractorTests` | 5 | Sorted extraction, ignore non-images, file content, empty ZIP, nested directories | ## Verification | Check | Result | |-------|--------| | `dotnet build` | ✅ 0 errors, 0 warnings | | `dotnet test` | ✅ 47/47 pass | | `git diff --check` | ✅ Clean | ## Related ADRs - [ADR-0005: UUID IDs and Backend-Owned Image Storage](docs/adr/0005-uuid-ids-and-backend-owned-image-storage.md) - [ADR-0009: No Import System — Upload Only](docs/adr/0009-no-import-system-upload-only.md) - [ADR-0012: .NET 10 and SkiaSharp](docs/adr/0012-dotnet-10-and-skiasharp.md)
ApplicationCore ports:
- IImageInspector: decode image metadata (dimensions, mediaType, sha256)
- IImageStorage: save/read/delete/exists for UUID-sharded image files
- IThumbnailGenerator: generate WebP thumbnails from image stream
- IThumbnailStorage: save/read/delete/exists for WebP thumbnails
- IZipExtractor: extract images from ZIP sorted by path, ignore non-images

Infrastructure implementations:
- SkiaSharpImageInspector: SkiaSharp decode + magic-byte format detection
  (JPEG/PNG/GIF/WebP/BMP) + SHA-256 hashing
- SkiaSharpThumbnailGenerator: Mitchell cubic resize preserving aspect ratio,
  WebP encode at quality 80, no upscaling
- FilesystemImageStorage: {imageDir}/{shard}/{uuid}.ext with auto-mkdir
- FilesystemThumbnailStorage: {thumbnailDir}/{shard}/{uuid}.webp
- ZipExtractor: ZipArchive-based, IAsyncEnumerable, EnumeratorCancellation

SkiaSharp package strategy (following OpenRouter.Net pattern):
- Infrastructure: SkiaSharp (managed wrapper only)
- Server: SkiaSharp.NativeAssets.Linux.NoDependencies (libSkiaSharp.so)
- Infrastructure.Tests: SkiaSharp.NativeAssets.Linux.NoDependencies
- All pinned at 3.119.4 via CPM

Server DI registration:
- IImageInspector, IThumbnailGenerator, IZipExtractor as singletons
- IImageStorage, IThumbnailStorage with env-var paths
  (DOUJIN_MANAGER_IMAGE_DIR, DOUJIN_MANAGER_THUMBNAIL_DIR)

Tests (24 new, 47 total, all passing):
- FilesystemImageStorage: 5 tests (save, read, exists, delete, missing file)
- FilesystemThumbnailStorage: 4 tests (save, exists, read, delete)
- SkiaSharpImageInspector: 5 tests (jpeg, png, sha256, different hashes, invalid)
- SkiaSharpThumbnailGenerator: 5 tests (webp output, aspect ratio, no upscale, square, invalid)
- ZipExtractor: 5 tests (sorted extraction, ignore non-images, content, empty, nested dirs)
@ -0,0 +10,4 @@
/// </summary>
public class SkiaSharpImageInspector : IImageInspector
{
private static readonly string[] SupportedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
Owner

I would like you to centralize the supported file formats: Single source of truth. Both this class and ZipExtractor have the exact same list of items. Considering that it is SkiaSharpImageInspector who defines the supported formats, ZipExtractor shouldn't know this information at all, but at best retrieve it.

I would like you to centralize the supported file formats: Single source of truth. Both this class and ZipExtractor have the exact same list of items. Considering that it is SkiaSharpImageInspector who defines the supported formats, ZipExtractor shouldn't know this information at all, but at best retrieve it.
bjoern marked this conversation as resolved
Author
Member

🤖 Hermes automated review: changes requested

Independent review of this PR (head b9dd78f7, 21 files, +919). Local checks ran clean:

  • dotnet build → 0 errors, 0 warnings
  • dotnet test → 47/47 pass (24 new tests in Infrastructure.Tests)

Two blocking findings before this should merge. Details with file:line references and suggested fixes below.


🔴 Major

1. backend/src/DoujinManager.Infrastructure/Archives/ZipExtractor.cs:38 — Unbounded memory allocation from untrusted ZIP input
Each entry is copied entirely into a MemoryStream before being yielded:

var ms = new MemoryStream();
await entryStream.CopyToAsync(ms, cancellationToken);

A malicious "decompression bomb" (tiny compressed file → gigabytes decompressed) or a large legitimate archive will OOM the process. Since ZIPs come from user uploads, this is the highest-risk issue.
Suggested fix: Check entry.Length against a configurable max (e.g. 100 MB) before copying, and pass a bounded buffer to CopyToAsync. Consider streaming instead of buffering when the caller can consume incrementally.

2. backend/src/DoujinManager.Infrastructure/Archives/ZipExtractor.cs:16 — Archive disposal on partial/abandoned enumeration
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false) disposes only when the iterator completes normally. If a caller breaks out of await foreach early or an exception propagates, disposal depends on the caller DisposeAsync-ing the enumerator (which await foreach does, but only if the loop is exited via break/exception, not if the enumerator is abandoned). Also, leaveOpen: false disposes the underlying zipStream — surprising if the caller owns that stream.
Suggested fix: Move the archive into a try/finally inside the iterator, or switch leaveOpen to true and document stream ownership. At minimum, add a doc comment stating the caller must fully consume or await DisposeAsync the enumerator.


🟡 Minor (non-blocking)

3. backend/src/DoujinManager.Infrastructure/Storage/FilesystemImageStorage.cs:47-50 — No validation of extension parameter
extension is concatenated into the path with no check. ImageFileId is a Guid (safe), but a caller passing extension: "../../../etc/passwd" would traverse. Current callers are internal/trusted, but defense-in-depth is cheap here.
Suggested fix: Validate extension against the same ImageExtensions whitelist used in ZipExtractor, or assert it matches ^\.[a-z0-9]+$.

4. backend/src/DoujinManager.Infrastructure/Images/SkiaSharpImageInspector.cs:16-18 — Entire stream loaded into memory with no size cap
stream.CopyToAsync(ms) with no limit. A huge uploaded image causes OOM, same class of issue as finding #1.
Suggested fix: Enforce a max size (check stream.Length if seekable, or use a bounded CopyToAsync variant).

5. backend/src/DoujinManager.Infrastructure/Images/SkiaSharpThumbnailGenerator.cs:34-36 — Cancellation only checked once
ThrowIfCancellationRequested runs at the start of Task.Run, but the CPU-bound decode/resize/encode cannot be interrupted mid-operation. Cancellation is delayed for large images. Not a correctness bug; note for future.

6. FilesystemImageStorage.cs:35 / FilesystemThumbnailStorage.cs:40 — Unnecessary TOCTOU on delete
if (File.Exists(path)) File.Delete(path) has a benign race. File.Delete is a no-op on a missing file on .NET, so the Exists guard is misleading and unnecessary — just call File.Delete(path) directly.


What looks good

  • Clean Clean Architecture: ports in ApplicationCore, implementations in Infrastructure.
  • ImageFileId is a strongly-typed Guid — no path traversal via the ID.
  • No zip-slip risk: ZipExtractor yields entry.Name (basename) + MemoryStream, never writes archive paths to disk.
  • SkiaSharp native asset strategy (matching OpenRouter.Net) is sound.
  • Tests are meaningful — they verify dimensions, aspect ratio, no-upscale, SHA-256 determinism, sorted extraction, and error cases, not just smoke.
  • Build and all 47 tests pass locally on the head SHA.

Automated daily review. I never merge PRs. This is a conversation comment, not a formal Forgejo review approval — the current MCP integration cannot create review approval states.

## 🤖 Hermes automated review: changes requested Independent review of this PR (head `b9dd78f7`, 21 files, +919). Local checks ran clean: - `dotnet build` → 0 errors, 0 warnings ✅ - `dotnet test` → 47/47 pass (24 new tests in `Infrastructure.Tests`) ✅ Two blocking findings before this should merge. Details with `file:line` references and suggested fixes below. --- ### 🔴 Major **1. `backend/src/DoujinManager.Infrastructure/Archives/ZipExtractor.cs:38` — Unbounded memory allocation from untrusted ZIP input** Each entry is copied entirely into a `MemoryStream` before being yielded: ```csharp var ms = new MemoryStream(); await entryStream.CopyToAsync(ms, cancellationToken); ``` A malicious "decompression bomb" (tiny compressed file → gigabytes decompressed) or a large legitimate archive will OOM the process. Since ZIPs come from user uploads, this is the highest-risk issue. **Suggested fix:** Check `entry.Length` against a configurable max (e.g. 100 MB) before copying, and pass a bounded buffer to `CopyToAsync`. Consider streaming instead of buffering when the caller can consume incrementally. **2. `backend/src/DoujinManager.Infrastructure/Archives/ZipExtractor.cs:16` — Archive disposal on partial/abandoned enumeration** `using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false)` disposes only when the iterator completes normally. If a caller breaks out of `await foreach` early or an exception propagates, disposal depends on the caller `DisposeAsync`-ing the enumerator (which `await foreach` does, but only if the loop is exited via `break`/exception, not if the enumerator is abandoned). Also, `leaveOpen: false` disposes the underlying `zipStream` — surprising if the caller owns that stream. **Suggested fix:** Move the archive into a `try/finally` inside the iterator, or switch `leaveOpen` to `true` and document stream ownership. At minimum, add a doc comment stating the caller must fully consume or `await DisposeAsync` the enumerator. --- ### 🟡 Minor (non-blocking) **3. `backend/src/DoujinManager.Infrastructure/Storage/FilesystemImageStorage.cs:47-50` — No validation of `extension` parameter** `extension` is concatenated into the path with no check. `ImageFileId` is a `Guid` (safe), but a caller passing `extension: "../../../etc/passwd"` would traverse. Current callers are internal/trusted, but defense-in-depth is cheap here. **Suggested fix:** Validate `extension` against the same `ImageExtensions` whitelist used in `ZipExtractor`, or assert it matches `^\.[a-z0-9]+$`. **4. `backend/src/DoujinManager.Infrastructure/Images/SkiaSharpImageInspector.cs:16-18` — Entire stream loaded into memory with no size cap** `stream.CopyToAsync(ms)` with no limit. A huge uploaded image causes OOM, same class of issue as finding #1. **Suggested fix:** Enforce a max size (check `stream.Length` if seekable, or use a bounded `CopyToAsync` variant). **5. `backend/src/DoujinManager.Infrastructure/Images/SkiaSharpThumbnailGenerator.cs:34-36` — Cancellation only checked once** `ThrowIfCancellationRequested` runs at the start of `Task.Run`, but the CPU-bound decode/resize/encode cannot be interrupted mid-operation. Cancellation is delayed for large images. Not a correctness bug; note for future. **6. `FilesystemImageStorage.cs:35` / `FilesystemThumbnailStorage.cs:40` — Unnecessary TOCTOU on delete** `if (File.Exists(path)) File.Delete(path)` has a benign race. `File.Delete` is a no-op on a missing file on .NET, so the `Exists` guard is misleading and unnecessary — just call `File.Delete(path)` directly. --- ### ✅ What looks good - Clean Clean Architecture: ports in `ApplicationCore`, implementations in `Infrastructure`. - `ImageFileId` is a strongly-typed `Guid` — no path traversal via the ID. - No zip-slip risk: `ZipExtractor` yields `entry.Name` (basename) + `MemoryStream`, never writes archive paths to disk. - SkiaSharp native asset strategy (matching `OpenRouter.Net`) is sound. - Tests are meaningful — they verify dimensions, aspect ratio, no-upscale, SHA-256 determinism, sorted extraction, and error cases, not just smoke. - Build and all 47 tests pass locally on the head SHA. --- *Automated daily review. I never merge PRs. This is a conversation comment, not a formal Forgejo review approval — the current MCP integration cannot create review approval states.*
Owner

@hermes-agent wrote in #3 (comment):

🤖 Hermes automated review: changes requested

1. backend/src/DoujinManager.Infrastructure/Archives/ZipExtractor.cs:38 — Unbounded memory allocation from untrusted ZIP input Each entry is copied entirely into a MemoryStream before being yielded:

var ms = new MemoryStream();
await entryStream.CopyToAsync(ms, cancellationToken);

A malicious "decompression bomb" (tiny compressed file → gigabytes decompressed) or a large legitimate archive will OOM the process. Since ZIPs come from user uploads, this is the highest-risk issue. Suggested fix: Check entry.Length against a configurable max (e.g. 100 MB) before copying, and pass a bounded buffer to CopyToAsync. Consider streaming instead of buffering when the caller can consume incrementally.

The limit should be a bit higher than that. Valid large uploads of multiple 100 images with decent quality images can easily be reaching 500MB

@hermes-agent wrote in https://git.kagaku.eu/TeamAI/doujin-manager/pulls/3#issuecomment-34: > ## [](#hermes-automated-review-changes-requested):robot: Hermes automated review: changes requested > > **1. `backend/src/DoujinManager.Infrastructure/Archives/ZipExtractor.cs:38` — Unbounded memory allocation from untrusted ZIP input** Each entry is copied entirely into a `MemoryStream` before being yielded: > > ```csharp > var ms = new MemoryStream(); > await entryStream.CopyToAsync(ms, cancellationToken); > ``` > > A malicious "decompression bomb" (tiny compressed file → gigabytes decompressed) or a large legitimate archive will OOM the process. Since ZIPs come from user uploads, this is the highest-risk issue. **Suggested fix:** Check `entry.Length` against a configurable max (e.g. 100 MB) before copying, and pass a bounded buffer to `CopyToAsync`. Consider streaming instead of buffering when the caller can consume incrementally. > The limit should be a bit higher than that. Valid large uploads of multiple 100 images with decent quality images can easily be reaching 500MB
Addresses all blocking and non-blocking findings from PR #3 review:

#1 (Major) ZipExtractor decompression bomb protection:
  - Added maxEntrySizeBytes parameter (default: 500MB per bjoern's feedback)
  - Entries exceeding the limit are silently skipped
  - Pre-allocate MemoryStream capacity from entry.Length

#2 (Major) ZipExtractor stream ownership:
  - Changed leaveOpen from false to true — caller owns the stream
  - Added XML doc comments documenting stream ownership and
    enumerator consumption requirements

#3 (Minor) FilesystemImageStorage extension validation:
  - Added regex validation: ^\.[a-z0-9]+$
  - Prevents path traversal via malicious extension parameter
  - All public methods validate before use

#4 (Minor) SkiaSharpImageInspector size cap:
  - Added maxSizeBytes parameter (default: 500MB)
  - Pre-checks stream.Length if seekable
  - Bounded copy loop for non-seekable streams
  - Throws ArgumentException if exceeded

#6 (Minor) File delete TOCTOU:
  - Removed File.Exists guard before File.Delete
  - Catch DirectoryNotFoundException instead (File.Delete throws
    when parent directory doesn't exist, contrary to review assumption)
  - Both FilesystemImageStorage and FilesystemThumbnailStorage

#5 (Minor) Thumbnail cancellation — noted for future, not addressed

New tests (7 added, 54 total):
  - ZipExtractor: skip oversized entries, caller stream not disposed
  - FilesystemImageStorage: invalid extension, empty extension, delete no-op
  - FilesystemThumbnailStorage: delete no-op
  - SkiaSharpImageInspector: stream exceeds max size
Author
Member

Review feedback addressed — commit 1f1ef60

All blocking and non-blocking findings have been fixed. 54 tests pass (7 new).

🔴 Major findings

#1 — ZipExtractor unbounded memory (decompression bomb)

  • Added maxEntrySizeBytes parameter to IZipExtractor.ExtractAsync (default: 500 MB per @bjoern's feedback)
  • Entries exceeding the limit are silently skipped
  • MemoryStream pre-allocated from entry.Length to avoid over-allocation
  • Test: ExtractAsync_Skips_Entries_Exceeding_MaxSize

#2 — ZipExtractor archive disposal / stream ownership

  • Changed leaveOpen from false to true — caller owns the stream
  • Added XML doc comments on the class documenting: stream ownership, enumerator consumption requirement
  • Test: ExtractAsync_Does_Not_Dispose_Caller_Stream

🟡 Minor findings

#3 — FilesystemImageStorage extension validation

  • Added ^\.[a-z0-9]+$ regex validation on all public methods
  • Path traversal via ../../../etc/passwd is now rejected
  • Tests: SaveAsync_Throws_For_Invalid_Extension, SaveAsync_Throws_For_Empty_Extension

#4 — SkiaSharpImageInspector size cap

  • Added maxSizeBytes parameter to IImageInspector.InspectAsync (default: 500 MB)
  • Pre-checks stream.Length if seekable; bounded read loop for non-seekable streams
  • Test: InspectAsync_Throws_When_Stream_Exceeds_MaxSize

#5 — ThumbnailGenerator cancellation

  • Noted for future. SkiaSharp's CPU-bound operations can't be interrupted mid-operation. Not a correctness bug.

#6 — File.Delete TOCTOU

  • Removed the File.Exists guard. However, File.Delete on .NET throws DirectoryNotFoundException when the parent directory doesn't exist (contrary to the review's assumption that it's a no-op). Added a catch (DirectoryNotFoundException) to handle this cleanly.
  • Tests: DeleteAsync_Does_Not_Throw_When_File_Does_Not_Exist (both storage classes)
## Review feedback addressed — commit `1f1ef60` All blocking and non-blocking findings have been fixed. 54 tests pass (7 new). ### 🔴 Major findings **#1 — ZipExtractor unbounded memory (decompression bomb)** - Added `maxEntrySizeBytes` parameter to `IZipExtractor.ExtractAsync` (default: **500 MB** per @bjoern's feedback) - Entries exceeding the limit are silently skipped - `MemoryStream` pre-allocated from `entry.Length` to avoid over-allocation - ✅ Test: `ExtractAsync_Skips_Entries_Exceeding_MaxSize` **#2 — ZipExtractor archive disposal / stream ownership** - Changed `leaveOpen` from `false` to `true` — caller owns the stream - Added XML doc comments on the class documenting: stream ownership, enumerator consumption requirement - ✅ Test: `ExtractAsync_Does_Not_Dispose_Caller_Stream` ### 🟡 Minor findings **#3 — FilesystemImageStorage extension validation** - Added `^\.[a-z0-9]+$` regex validation on all public methods - Path traversal via `../../../etc/passwd` is now rejected - ✅ Tests: `SaveAsync_Throws_For_Invalid_Extension`, `SaveAsync_Throws_For_Empty_Extension` **#4 — SkiaSharpImageInspector size cap** - Added `maxSizeBytes` parameter to `IImageInspector.InspectAsync` (default: 500 MB) - Pre-checks `stream.Length` if seekable; bounded read loop for non-seekable streams - ✅ Test: `InspectAsync_Throws_When_Stream_Exceeds_MaxSize` **#5 — ThumbnailGenerator cancellation** - Noted for future. SkiaSharp's CPU-bound operations can't be interrupted mid-operation. Not a correctness bug. **#6 — File.Delete TOCTOU** - Removed the `File.Exists` guard. However, `File.Delete` on .NET throws `DirectoryNotFoundException` when the parent directory doesn't exist (contrary to the review's assumption that it's a no-op). Added a `catch (DirectoryNotFoundException)` to handle this cleanly. - ✅ Tests: `DeleteAsync_Does_Not_Throw_When_File_Does_Not_Exist` (both storage classes)
IImageInspector now exposes SupportedExtensions — the inspector owns
the list of formats it can decode. ZipExtractor receives the set via
constructor injection instead of maintaining its own duplicate list.

- IImageInspector: added IReadOnlySet<string> SupportedExtensions property
- SkiaSharpImageInspector: exposes its extensions as the single source
- ZipExtractor: takes IReadOnlySet<string> via constructor, no hardcoded list
- Server Program.cs: creates inspector first, passes SupportedExtensions
  to ZipExtractor
- Tests: inspector exposes expected formats, extractor respects provided set
Author
Member

Single source of truth — commit 31c3563

Fixed. The supported extensions list now lives in one place.

Changes:

  • IImageInspector now exposes IReadOnlySet<string> SupportedExtensions — the inspector is the single source of truth for which image formats are supported
  • SkiaSharpImageInspector publishes its extensions set as a public property
  • ZipExtractor takes IReadOnlySet<string> via constructor injection — it no longer has any knowledge of which formats are supported, it just filters using whatever set it's given
  • Server/Program.cs creates the inspector first, then passes inspector.SupportedExtensions to the ZipExtractor constructor

Tests added:

  • SkiaSharpImageInspectorTests.SupportedExtensions_Contains_Expected_Formats — verifies the inspector exposes all 6 formats
  • ZipExtractorTests.ExtractAsync_Uses_Provided_Extensions_Set — verifies the extractor respects the injected set (creates an extractor with only .png, confirms .jpg entries are filtered out)

56 tests pass, 0 warnings.

## Single source of truth — commit `31c3563` Fixed. The supported extensions list now lives in one place. **Changes:** - `IImageInspector` now exposes `IReadOnlySet<string> SupportedExtensions` — the inspector is the single source of truth for which image formats are supported - `SkiaSharpImageInspector` publishes its extensions set as a public property - `ZipExtractor` takes `IReadOnlySet<string>` via constructor injection — it no longer has any knowledge of which formats are supported, it just filters using whatever set it's given - `Server/Program.cs` creates the inspector first, then passes `inspector.SupportedExtensions` to the `ZipExtractor` constructor **Tests added:** - `SkiaSharpImageInspectorTests.SupportedExtensions_Contains_Expected_Formats` — verifies the inspector exposes all 6 formats - `ZipExtractorTests.ExtractAsync_Uses_Provided_Extensions_Set` — verifies the extractor respects the injected set (creates an extractor with only `.png`, confirms `.jpg` entries are filtered out) 56 tests pass, 0 warnings.
Author
Member

🤖 Hermes automated review: changes requested

Reviewed the updated head 31c3563 after the earlier fixes. Local checks passed:

  • git diff --check → clean
  • dotnet build --no-restore backend/DoujinManager.slnx → 0 warnings, 0 errors
  • dotnet test --no-restore --no-build backend/DoujinManager.slnx → 56/56 tests passed

The prior ZIP-size/stream-ownership/extension-source findings look addressed. I found one remaining blocking DoS risk before this should merge.


🔴 Major

1. backend/src/DoujinManager.Infrastructure/Images/SkiaSharpImageInspector.cs:51 and backend/src/DoujinManager.Infrastructure/Images/SkiaSharpThumbnailGenerator.cs:21 — compressed-size limits do not protect against image pixel bombs

The new 500 MB byte limit is useful, but both image paths still fully decode attacker-controlled image data into an SKBitmap:

// SkiaSharpImageInspector.cs
using var bitmap = SKBitmap.Decode(bytes);

// SkiaSharpThumbnailGenerator.cs
using var bitmap = SKBitmap.Decode(imageStream);

A malicious PNG/WebP/JPEG can be small on disk but declare extremely large dimensions. That stays under maxSizeBytes, then SKBitmap.Decode(...) attempts to allocate memory based on decoded pixels, potentially exhausting process memory. The current tests cover compressed byte size, but not decoded pixel dimensions.

Suggested fix: inspect image metadata before full bitmap decode and enforce a maximum decoded pixel area/dimension, e.g. via SKCodec.Create(...) / codec.Info (or equivalent bounds-only decode) before allocating an SKBitmap. Apply the same limit in SkiaSharpThumbnailGenerator, or make thumbnail generation require/consume a previously validated ImageInspection. Add tests for a small-byte image with dimensions above the allowed pixel cap.


🟡 Minor (non-blocking)

2. backend/src/DoujinManager.Infrastructure/Archives/ZipExtractor.cs:43-44 — oversized ZIP entries are silently dropped

Skipping entries above maxEntrySizeBytes avoids OOM, but it can also produce incomplete imports without any signal to the caller/user. This is especially easy to miss later when REST endpoints are added.

Suggested fix: consider returning skipped-entry metadata or throwing a domain exception for oversized image entries so upload/import code can show an explicit error instead of silently losing pages.


Automated daily review. I never merge PRs. This is a conversation comment, not a formal Forgejo review approval — the current MCP integration cannot create review approval states.

## 🤖 Hermes automated review: changes requested Reviewed the updated head `31c3563` after the earlier fixes. Local checks passed: - `git diff --check` → clean ✅ - `dotnet build --no-restore backend/DoujinManager.slnx` → 0 warnings, 0 errors ✅ - `dotnet test --no-restore --no-build backend/DoujinManager.slnx` → 56/56 tests passed ✅ The prior ZIP-size/stream-ownership/extension-source findings look addressed. I found one remaining blocking DoS risk before this should merge. --- ### 🔴 Major **1. `backend/src/DoujinManager.Infrastructure/Images/SkiaSharpImageInspector.cs:51` and `backend/src/DoujinManager.Infrastructure/Images/SkiaSharpThumbnailGenerator.cs:21` — compressed-size limits do not protect against image pixel bombs** The new 500 MB byte limit is useful, but both image paths still fully decode attacker-controlled image data into an `SKBitmap`: ```csharp // SkiaSharpImageInspector.cs using var bitmap = SKBitmap.Decode(bytes); // SkiaSharpThumbnailGenerator.cs using var bitmap = SKBitmap.Decode(imageStream); ``` A malicious PNG/WebP/JPEG can be small on disk but declare extremely large dimensions. That stays under `maxSizeBytes`, then `SKBitmap.Decode(...)` attempts to allocate memory based on decoded pixels, potentially exhausting process memory. The current tests cover compressed byte size, but not decoded pixel dimensions. **Suggested fix:** inspect image metadata before full bitmap decode and enforce a maximum decoded pixel area/dimension, e.g. via `SKCodec.Create(...)` / `codec.Info` (or equivalent bounds-only decode) before allocating an `SKBitmap`. Apply the same limit in `SkiaSharpThumbnailGenerator`, or make thumbnail generation require/consume a previously validated `ImageInspection`. Add tests for a small-byte image with dimensions above the allowed pixel cap. --- ### 🟡 Minor (non-blocking) **2. `backend/src/DoujinManager.Infrastructure/Archives/ZipExtractor.cs:43-44` — oversized ZIP entries are silently dropped** Skipping entries above `maxEntrySizeBytes` avoids OOM, but it can also produce incomplete imports without any signal to the caller/user. This is especially easy to miss later when REST endpoints are added. **Suggested fix:** consider returning skipped-entry metadata or throwing a domain exception for oversized image entries so upload/import code can show an explicit error instead of silently losing pages. --- *Automated daily review. I never merge PRs. This is a conversation comment, not a formal Forgejo review approval — the current MCP integration cannot create review approval states.*
Addresses second-round review feedback (PR #3 comment #46):

#1 (Major) Pixel bomb protection:
- IImageInspector.InspectAsync now takes maxPixelDimension (default: 30,000)
- SkiaSharpImageInspector: uses SKCodec to read dimensions BEFORE
  allocating a full SKBitmap. Rejects images exceeding the limit.
- SkiaSharpThumbnailGenerator: added internal MaxPixelDimension guard
  using SKCodec before decode as secondary protection.
- Test: InspectAsync_Throws_For_Pixel_Bomb (100x100 image rejected
  when maxPixelDimension=50)

#2 (Minor) Oversized ZIP entries now throw instead of silent skip:
- ZipExtractor throws InvalidDataException with entry name and size
  so callers can surface the error to users
- Updated test: ExtractAsync_Throws_For_Entries_Exceeding_MaxSize

58 tests pass, 0 warnings.
Author
Member

Second-round review feedback addressed — commit dc91cc7

Both findings from comment #46 are fixed. 58 tests pass.

🔴 #1 — Pixel bomb protection

Inspector:

  • IImageInspector.InspectAsync now takes maxPixelDimension parameter (default: 30,000)
  • SkiaSharpImageInspector uses SKCodec to read declared dimensions before allocating a full SKBitmap
  • If width or height exceeds the limit, throws ArgumentException immediately — no pixel allocation

Thumbnail generator:

  • Added internal MaxPixelDimension = 30,000 guard
  • Uses SKCodec to check dimensions before full decode (secondary defense)

Test: InspectAsync_Throws_For_Pixel_Bomb_Small_File_Huge_Dimensions — a 100×100 image is rejected when maxPixelDimension is set to 50

🟡 #2 — Oversized ZIP entries now throw

  • ZipExtractor now throws InvalidDataException with the entry name and size instead of silently skipping
  • Callers can catch this and surface an error to the user

Test: ExtractAsync_Throws_For_Entries_Exceeding_MaxSize (updated from skip-expectation to throw-expectation)

## Second-round review feedback addressed — commit `dc91cc7` Both findings from comment #46 are fixed. 58 tests pass. ### 🔴 #1 — Pixel bomb protection **Inspector:** - `IImageInspector.InspectAsync` now takes `maxPixelDimension` parameter (default: 30,000) - `SkiaSharpImageInspector` uses `SKCodec` to read declared dimensions **before** allocating a full `SKBitmap` - If width or height exceeds the limit, throws `ArgumentException` immediately — no pixel allocation **Thumbnail generator:** - Added internal `MaxPixelDimension = 30,000` guard - Uses `SKCodec` to check dimensions before full decode (secondary defense) ✅ Test: `InspectAsync_Throws_For_Pixel_Bomb_Small_File_Huge_Dimensions` — a 100×100 image is rejected when `maxPixelDimension` is set to 50 ### 🟡 #2 — Oversized ZIP entries now throw - `ZipExtractor` now throws `InvalidDataException` with the entry name and size instead of silently skipping - Callers can catch this and surface an error to the user ✅ Test: `ExtractAsync_Throws_For_Entries_Exceeding_MaxSize` (updated from skip-expectation to throw-expectation)
bjoern merged commit 24b4ad80bd into main 2026-06-28 10:46:50 +02:00
bjoern deleted branch feat/storage-and-thumbnails 2026-06-28 10:46:50 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/doujin-manager!3
No description provided.