From a154d2ba26007bed203be54ebfe8c271bc60fded Mon Sep 17 00:00:00 2001 From: keremy321 Date: Fri, 14 Aug 2026 22:36:03 +0300 Subject: [PATCH] feat: add FileSentry C# client SDK --- .github/workflows/ci.yml | 3 + FileSentry.slnx | 1 + README.md | 40 +- docs/PROJECT_PLAN.md | 16 +- docs/ai/CURRENT_TASK.md | 118 ++--- docs/ai/PROJECT_CONTEXT.md | 27 +- src/FileSentry.Client/FileMetadata.cs | 11 + .../FileSentry.Client.csproj | 25 + .../FileSentryApiException.cs | 52 ++ src/FileSentry.Client/FileSentryClient.cs | 454 ++++++++++++++++++ .../FileSentryClientOptions.cs | 70 +++ src/FileSentry.Client/FileSentryException.cs | 3 + .../FileSentryPollingTimeoutException.cs | 12 + .../FileSentryProtocolException.cs | 3 + src/FileSentry.Client/FileStatus.cs | 11 + src/FileSentry.Client/FileUpload.cs | 10 + .../Internal/NonDisposingReadStream.cs | 57 +++ .../Internal/ResponseOwnedStream.cs | 87 ++++ src/FileSentry.Client/README.md | 49 ++ .../ClientSdkIntegrationTests.cs | 89 ++++ .../FileSentry.IntegrationTests.csproj | 1 + .../FileSentry.UnitTests.csproj | 1 + .../FileSentryClientTests.cs | 450 +++++++++++++++++ 23 files changed, 1517 insertions(+), 73 deletions(-) create mode 100644 src/FileSentry.Client/FileMetadata.cs create mode 100644 src/FileSentry.Client/FileSentry.Client.csproj create mode 100644 src/FileSentry.Client/FileSentryApiException.cs create mode 100644 src/FileSentry.Client/FileSentryClient.cs create mode 100644 src/FileSentry.Client/FileSentryClientOptions.cs create mode 100644 src/FileSentry.Client/FileSentryException.cs create mode 100644 src/FileSentry.Client/FileSentryPollingTimeoutException.cs create mode 100644 src/FileSentry.Client/FileSentryProtocolException.cs create mode 100644 src/FileSentry.Client/FileStatus.cs create mode 100644 src/FileSentry.Client/FileUpload.cs create mode 100644 src/FileSentry.Client/Internal/NonDisposingReadStream.cs create mode 100644 src/FileSentry.Client/Internal/ResponseOwnedStream.cs create mode 100644 src/FileSentry.Client/README.md create mode 100644 tests/FileSentry.IntegrationTests/ClientSdkIntegrationTests.cs create mode 100644 tests/FileSentry.UnitTests/FileSentryClientTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e138487..1f275d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,9 @@ jobs: - name: Run automated tests run: dotnet test FileSentry.slnx --configuration Release --no-build --no-restore + - name: Validate client package + run: dotnet pack src/FileSentry.Client/FileSentry.Client.csproj --configuration Release --no-build --no-restore --output artifacts/packages + - name: Verify formatting run: dotnet format FileSentry.slnx --verify-no-changes --no-restore diff --git a/FileSentry.slnx b/FileSentry.slnx index 6ca69dc..31af1a2 100644 --- a/FileSentry.slnx +++ b/FileSentry.slnx @@ -4,4 +4,5 @@ + diff --git a/README.md b/README.md index 9ca854b..c0578b4 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,42 @@ A non-clean download returns `409` with code `FILE_NOT_CLEAN`. Missing and cross-owner IDs return the same `404 FILE_NOT_FOUND` response. Errors use `application/problem+json` with a stable `code` extension. +## C# client SDK + +The pack-ready [.NET client](src/FileSentry.Client/README.md) wraps the existing +service-authenticated `/api/v1/files` contract. It is currently versioned +`1.1.0-preview.1` for development and has not been published to NuGet. + +```csharp +using FileSentry.Client; + +using var client = new FileSentryClient(new FileSentryClientOptions +{ + BaseAddress = new Uri("https://filesentry.example/"), + ApiKey = configuration["FileSentry:ApiKey"]!, + PollingInterval = TimeSpan.FromSeconds(1), + ScanTimeout = TimeSpan.FromMinutes(5) +}); + +await using Stream uploadContent = File.OpenRead("cv.pdf"); +FileUpload upload = await client.UploadAsync(uploadContent, "cv.pdf", cancellationToken); +FileMetadata result = await client.WaitForScanAsync(upload.FileId, cancellationToken); + +if (result.Status == FileStatus.Clean) +{ + await using Stream clean = await client.DownloadAsync( + upload.FileId, + cancellationToken); + // Consume the stream while still treating its content as potentially risky. +} +``` + +Use the sequence `Upload -> Wait for terminal scan status -> continue only if Clean +-> Download`. `Infected`, `ScanFailed`, and `Deleted` stop polling without becoming +successful. Unknown or malformed states fail safely. Uploads are not retried +automatically, and API ProblemDetails are available through typed, credential- +redacted SDK exceptions. + ## Audit, correlation, and rate limiting - Every request receives an `X-Correlation-ID`. A valid caller value is reused; @@ -314,8 +350,8 @@ sample. `.github/workflows/ci.yml` runs on pull requests and pushes to `main`. Its least-privilege Ubuntu job installs the exact SDK, restores, validates Compose, pre-pulls the existing Testcontainers images, builds in Release, builds the API -container image, runs the complete test suite, verifies formatting, and audits -direct and transitive NuGet packages. +container image, runs the complete test suite, validates the unpublished client +package, verifies formatting, and audits direct and transitive NuGet packages. CI uses runtime-generated Testcontainers credentials and step-scoped Compose placeholders. It requires no repository secrets or developer User Secrets. The diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 948e103..626f340 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -155,14 +155,14 @@ grows. 4. Owner-authorized metadata, download, and delete operations. 5. Audit, correlation, rate limiting, operational hardening, and CI. -### Current v1.1 phase: containerized API and service authentication - -- run the API/worker, migrations, PostgreSQL, ClamAV, and persistent storage as one - Compose stack; -- keep database and scanner ports internal while exposing only the API; -- add an externally configured owner-scoped service credential without breaking - JWT callers; -- keep SDKs, quotas, retention, cloud storage, and messaging in later milestones. +### Current v1.1 phase: C# client SDK foundation + +- provide a pack-ready .NET 10 client for service-authenticated upload, metadata, + list, scan polling, clean download, and delete; +- stream file content and preserve typed ProblemDetails without exposing the key; +- stop polling safely on clean and non-clean terminal states; +- keep NuGet publishing, Python SDK, webhooks, quotas, retention, cloud storage, + and messaging in later milestones. Local verification results and release blockers are recorded in `docs/ai/CURRENT_TASK.md`. No tag or release is created by this milestone. diff --git a/docs/ai/CURRENT_TASK.md b/docs/ai/CURRENT_TASK.md index d002763..09c1324 100644 --- a/docs/ai/CURRENT_TASK.md +++ b/docs/ai/CURRENT_TASK.md @@ -1,78 +1,80 @@ -# Current Task: Containerized API and Service Authentication +# Current Task: C# Client SDK Foundation ## Milestone -`feat/containerized-api-service-auth` is the first FileSentry `v1.1.0` milestone. -It makes the existing API/worker deployable as part of one Docker Compose stack and -adds an additive credential for trusted backend callers without changing `/api/v1` -or weakening JWT user behavior. +`feat/client-sdk-foundation` is the second FileSentry `v1.1.0` milestone. It adds a +reusable .NET 10 client library for the existing `/api/v1/files` service API without +changing server workflow or authentication behavior. ## Scope -- Publish the .NET 10 API and background scanner in a production-style multi-stage - image that runs as a non-root user. -- Start the API/worker, PostgreSQL, a one-shot migration command, and ClamAV through - one Compose project with persistent temp/quarantine/clean storage. -- Keep PostgreSQL and ClamAV internal to the Compose network; only the API receives - an explicitly configured host binding. -- Add startup-validated `X-Api-Key` service authentication using constant-time - secret comparison and a configured passwordless internal owner identity. -- Permit service identities to upload, list, read metadata, download clean owned - files, and delete owned files. Do not permit service access to registration, - login, `/auth/me`, foreign files, or administrative behavior. -- Preserve Identity/JWT, scanning, ownership, audit/correlation, rate limiting, - health endpoints, and all existing tests. +- Add a packable `src/FileSentry.Client` project to the solution with pre-release + NuGet metadata, no publishing credentials, and no unnecessary dependencies. +- Provide asynchronous `UploadAsync`, `GetFileAsync`, `ListFilesAsync`, + `WaitForScanAsync`, `DownloadAsync`, and `DeleteAsync` operations. +- Attach the configured `X-Api-Key` to each SDK request without mutating shared + `HttpClient.DefaultRequestHeaders`. +- Stream caller-owned upload content and response-owned download content without + buffering whole files or exposing server storage details. +- Model public file metadata/status and preserve bounded ProblemDetails fields in + typed exceptions without exposing the configured credential. +- Poll at configurable intervals with a bounded timeout, stop on every terminal + state, and fail safely on unknown or malformed status responses. +- Add deterministic fake-handler tests plus a PostgreSQL-backed real API/worker + integration path that uploads, reaches `Clean`, downloads byte-identically, and + deletes through the SDK. ## Security decisions -- The service credential is supplied only through external configuration and is - never returned, persisted, or logged. -- A service ID is a stable GUID and remains subject to the same database owner - predicates and upload rate limiter as a JWT subject. -- The service owner row has no password and uses a reserved internal identifier; - migration/provisioning fails on an identity collision. -- Requests containing a Bearer header are validated as JWT even if an API-key - header is also present, preventing fallback from a bad bearer token. -- ClamAV continues receiving bytes through `INSTREAM`; storage is never mounted - into the scanner container. +- The server remains authoritative for hashing, file validation, ownership, + scanning, and clean-download authorization; the SDK duplicates none of those + decisions. +- `Clean` is the only status a consumer should use to continue to download or + process content. `Infected`, `ScanFailed`, and `Deleted` are terminal but never + successful; unknown states produce a protocol failure rather than being treated + as clean or polled indefinitely. +- Uploads are never retried automatically, and caller-provided upload streams are + not owned or disposed by the SDK. +- A returned download stream owns its HTTP response and releases it when disposed. +- API keys, upload bytes, and download bytes are not logged or included in SDK + exception text. Any reflected configured key is redacted from parsed error data. ## Out of scope -C# or Python SDKs, webhooks, quotas, retention, cloud storage, message brokers, -public sharing, admin UI, and API redesign remain later milestones. +NuGet publishing, Python SDK, webhooks, automatic upload retries, dependency- +injection extensions, quotas/retention, cloud storage, UI, API v2, and scanning +changes remain later milestones. ## Verification target -Restore, Release build, full tests, formatting, Compose validation, image build, -full-stack startup/migration/health, JWT and service-authenticated live calls, a -containerized clean upload/scan, internal-only dependency ports, vulnerability -audit, and final diff/status inspection must all be executed before completion. +Restore, zero-warning Release build, full tests, formatting, direct/transitive +NuGet vulnerability audit, package creation/metadata inspection, final diff check, +and Git status inspection must pass. The real API integration path must demonstrate +an SDK upload reaching `Clean` and a byte-identical streamed download. ## Verified implementation state -- The exact pinned .NET SDK restored and built the solution in Release with zero - warnings and zero errors. -- All 31 unit and 83 integration tests pass. Coverage includes valid/missing/invalid - service credentials, Bearer precedence, startup validation, passwordless service - provisioning, owner isolation, the service/JWT file lifecycle, and secret-safe - responses/logs. -- Formatting and the direct/transitive NuGet vulnerability audit pass; no known - vulnerable packages were reported by the configured sources. -- Compose configuration and the multi-stage API image build pass. A clean - disposable stack applied migrations, provisioned the service owner, reached - healthy live/ready status, and ran the API process as UID 1654. -- Only the API was published, on loopback for verification. PostgreSQL and ClamAV - had no host mappings. Fresh application volumes were owned by `app:app` with - mode `700`. -- Live calls verified registration/login/JWT access, valid/missing/invalid service - authentication, JWT/service cross-owner `404` behavior, a clean PDF upload from - `PendingScan` to `Clean`, byte-identical download, deletion, and JWT-only - `/auth/me`. -- Inspection found no configured signing key, service key, connection string, or - untrusted upload filename in API logs or HTTP response data. +- The public SDK exposes `UploadAsync`, `GetFileAsync`, `ListFilesAsync`, + `WaitForScanAsync`, `DownloadAsync`, and `DeleteAsync` with cancellation support. +- Uploads are multipart streamed without taking ownership of the caller stream; + downloads remain unread until consumed and release their HTTP response when the + returned stream is disposed. +- Fourteen deterministic SDK tests cover per-request credentials, streaming, + response models, download lifetime, delete, clean/infected/failed polling, + cancellation, ProblemDetails mapping/redaction, and malformed/unknown responses. +- A PostgreSQL-backed API/worker integration test verified SDK upload from + `PendingScan` to `Clean`, byte-identical streamed download, and deletion. +- The exact pinned SDK restored and built all four projects in Release with zero + warnings and zero errors. All 45 unit and 84 integration tests pass, and formatting + verification succeeds. +- The direct/transitive NuGet audit reports no known vulnerable packages from the + configured sources. +- `FileSentry.Client.1.1.0-preview.1.nupkg` packs successfully with the SDK README, + MIT expression, repository metadata, `net10.0` assembly, and no runtime package + dependencies. The disposable package was inspected and removed; nothing was + published. -The updated GitHub Actions workflow now also builds the API image. Its hosted run -on this uncommitted branch is not verifiable locally and remains the only -environment-specific check. +The updated hosted CI run remains unverified because this task does not commit or +push. -Recommended next milestone: `feat/client-sdk-foundation`. +Recommended next milestone: `feat/sdk-integration-examples`. diff --git a/docs/ai/PROJECT_CONTEXT.md b/docs/ai/PROJECT_CONTEXT.md index 38d43d6..6e80efd 100644 --- a/docs/ai/PROJECT_CONTEXT.md +++ b/docs/ai/PROJECT_CONTEXT.md @@ -18,6 +18,7 @@ The primary design rule is: |---|---| | Runtime | .NET 10, exact SDK pinned in `global.json` | | API | ASP.NET Core controllers with development OpenAPI | +| C# client | Pack-ready .NET 10 `FileSentry.Client` library using service API-key authentication | | Architecture | Modular monolith | | Database | PostgreSQL 17 | | Persistence | Entity Framework Core with Npgsql migrations | @@ -45,6 +46,7 @@ FileSentry/ | |-- PROJECT_PLAN.md | `-- threat-model.md |-- src/FileSentry.Api/ +|-- src/FileSentry.Client/ |-- tests/ | |-- FileSentry.UnitTests/ | `-- FileSentry.IntegrationTests/ @@ -66,6 +68,15 @@ owner metadata, streams only owner-controlled clean content, and coordinates durable deletion. Service callers have file-owner scope only; they cannot use registration, login, `/auth/me`, foreign-file, or administrative behavior. +### C# client + +`FileSentry.Client` wraps the existing file routes with streaming async methods for +upload, metadata/list, bounded status polling, clean download, and delete. It adds +the service key to individual requests rather than shared default headers. It +returns typed file/status models and bounded, credential-redacted ProblemDetails +exceptions. The server remains authoritative for format validation, hashing, +ownership, scanning, and download permission. + ### PostgreSQL PostgreSQL stores users, file records, scan attempts, security audit events, retry @@ -183,15 +194,16 @@ configured stable passwordless service owner before starting the API. ## Current verified state -The v1.0 MVP plus the first v1.1 milestone implement infrastructure health, +The v1.0 MVP plus the first two v1.1 milestones implement infrastructure health, Identity/JWT and service authentication, secure ingestion, durable ClamAV scanning and recovery, owner-protected file lifecycle, auditing/correlation/rate limiting, -an all-container Compose topology, adversarial tests, and GitHub Actions CI. +an all-container Compose topology, a pack-ready .NET client SDK, adversarial tests, +and GitHub Actions CI. Integration tests use disposable PostgreSQL 17 and real ClamAV Testcontainers. The -final local gate passed from an isolated clone: a zero-warning Release build, 31 -unit tests, 75 integration tests, formatting, migrations, Compose validation, and -the NuGet vulnerability audit all succeeded. Live clean, EICAR-infected, +final v1.0 local gate passed from an isolated clone with a zero-warning Release +build, the complete test suite, formatting, migrations, Compose validation, and the +NuGet vulnerability audit. Live clean, EICAR-infected, retry-exhausted scanner-outage, ownership, audit/correlation, and rate-limit flows also passed. Local development uses an ignored `deploy/.env` for Compose and .NET User Secrets (or equivalent external configuration) for the database connection @@ -205,6 +217,11 @@ Actions validates Compose and builds the API image in addition to the existing solution gates. The v1.1 workflow change is locally validated but has not yet run on a hosted runner because this task does not commit or push. +The SDK is versioned `1.1.0-preview.1` and intentionally remains unpublished. Its +safe consumer workflow is upload, wait for a terminal scan status, continue only on +`Clean`, then stream the download. A clean result reduces risk but is not a safety +guarantee. + ## Limitations and explicit non-goals - A clean antivirus result is not proof that a file is harmless. diff --git a/src/FileSentry.Client/FileMetadata.cs b/src/FileSentry.Client/FileMetadata.cs new file mode 100644 index 0000000..c73a4d5 --- /dev/null +++ b/src/FileSentry.Client/FileMetadata.cs @@ -0,0 +1,11 @@ +namespace FileSentry.Client; + +public sealed record FileMetadata( + Guid FileId, + string OriginalFileName, + long SizeBytes, + string Sha256, + string DetectedMediaType, + FileStatus Status, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); diff --git a/src/FileSentry.Client/FileSentry.Client.csproj b/src/FileSentry.Client/FileSentry.Client.csproj new file mode 100644 index 0000000..25f8238 --- /dev/null +++ b/src/FileSentry.Client/FileSentry.Client.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + FileSentry.Client + 1.1.0-preview.1 + FileSentry Contributors + Streaming .NET client for the FileSentry secure file-ingestion API. + MIT + https://github.com/keremy321/FileSentry + https://github.com/keremy321/FileSentry.git + git + filesentry;security;file-upload;malware-scanning + README.md + false + false + + + + + + + diff --git a/src/FileSentry.Client/FileSentryApiException.cs b/src/FileSentry.Client/FileSentryApiException.cs new file mode 100644 index 0000000..a1d513e --- /dev/null +++ b/src/FileSentry.Client/FileSentryApiException.cs @@ -0,0 +1,52 @@ +using System.Net; + +namespace FileSentry.Client; + +public sealed class FileSentryApiException : FileSentryException +{ + internal FileSentryApiException( + HttpStatusCode statusCode, + int? problemStatus, + string? code, + string? title, + string? detail, + string? type, + string? instance) + : base(CreateMessage(statusCode, code, title)) + { + StatusCode = statusCode; + ProblemStatus = problemStatus; + Code = code; + Title = title; + Detail = detail; + Type = type; + Instance = instance; + } + + public HttpStatusCode StatusCode { get; } + + public int? ProblemStatus { get; } + + public string? Code { get; } + + public string? Title { get; } + + public string? Detail { get; } + + public string? Type { get; } + + public string? Instance { get; } + + private static string CreateMessage( + HttpStatusCode statusCode, + string? code, + string? title) + { + string safeTitle = string.IsNullOrWhiteSpace(title) + ? "FileSentry API request failed" + : title; + return string.IsNullOrWhiteSpace(code) + ? $"{safeTitle} (HTTP {(int)statusCode})." + : $"{safeTitle} (HTTP {(int)statusCode}, code {code})."; + } +} diff --git a/src/FileSentry.Client/FileSentryClient.cs b/src/FileSentry.Client/FileSentryClient.cs new file mode 100644 index 0000000..5dff6e2 --- /dev/null +++ b/src/FileSentry.Client/FileSentryClient.cs @@ -0,0 +1,454 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using FileSentry.Client.Internal; + +namespace FileSentry.Client; + +public sealed class FileSentryClient : IDisposable +{ + private const string ApiKeyHeaderName = "X-Api-Key"; + private const int MaximumProblemDetailsBytes = 64 * 1024; + private const string FilesPath = "api/v1/files"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly Uri _baseAddress; + private readonly string _apiKey; + private readonly TimeSpan _pollingInterval; + private readonly TimeSpan _scanTimeout; + private bool _disposed; + + public FileSentryClient(FileSentryClientOptions options) + : this( + new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = false + }), + options, + ownsHttpClient: true) + { + } + + public FileSentryClient(HttpClient httpClient, FileSentryClientOptions options) + : this(httpClient, options, ownsHttpClient: false) + { + } + + private FileSentryClient( + HttpClient httpClient, + FileSentryClientOptions options, + bool ownsHttpClient) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(options); + ValidatedFileSentryClientOptions validatedOptions = options.Validate(); + _httpClient = httpClient; + _ownsHttpClient = ownsHttpClient; + _baseAddress = validatedOptions.BaseAddress; + _apiKey = validatedOptions.ApiKey; + _pollingInterval = validatedOptions.PollingInterval; + _scanTimeout = validatedOptions.ScanTimeout; + } + + public async Task UploadAsync( + Stream content, + string fileName, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(content); + if (!content.CanRead) + { + throw new ArgumentException("The upload stream must be readable.", nameof(content)); + } + + if (string.IsNullOrWhiteSpace(fileName) + || fileName.Any(character => character is '\r' or '\n')) + { + throw new ArgumentException( + "The upload filename must be non-empty and contain no line breaks.", + nameof(fileName)); + } + + using var request = CreateRequest(HttpMethod.Post, FilesPath); + using var multipart = new MultipartFormDataContent(); + using var streamContent = new StreamContent(new NonDisposingReadStream(content)); + streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + multipart.Add(streamContent, "file", fileName); + request.Content = multipart; + + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + await EnsureStatusAsync( + response, + HttpStatusCode.Accepted, + cancellationToken).ConfigureAwait(false); + FileUploadWireModel wireModel = await ReadRequiredJsonAsync( + response.Content, + cancellationToken).ConfigureAwait(false); + return MapUpload(wireModel); + } + + public async Task GetFileAsync( + Guid fileId, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + EnsureFileId(fileId); + using var request = CreateRequest(HttpMethod.Get, GetFilePath(fileId)); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + await EnsureStatusAsync(response, HttpStatusCode.OK, cancellationToken) + .ConfigureAwait(false); + FileMetadataWireModel wireModel = await ReadRequiredJsonAsync( + response.Content, + cancellationToken).ConfigureAwait(false); + return MapMetadata(wireModel); + } + + public async Task> ListFilesAsync( + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + using var request = CreateRequest(HttpMethod.Get, FilesPath); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + await EnsureStatusAsync(response, HttpStatusCode.OK, cancellationToken) + .ConfigureAwait(false); + FileMetadataWireModel?[] wireModels = + await ReadRequiredJsonAsync( + response.Content, + cancellationToken).ConfigureAwait(false); + return wireModels.Select(model => model is null + ? throw UnexpectedResponse() + : MapMetadata(model)).ToArray(); + } + + public async Task WaitForScanAsync( + Guid fileId, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + EnsureFileId(fileId); + using var timeoutSource = new CancellationTokenSource(_scanTimeout); + using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutSource.Token); + + try + { + while (true) + { + FileMetadata metadata = await GetFileAsync(fileId, linkedSource.Token) + .ConfigureAwait(false); + if (IsTerminal(metadata.Status)) + { + return metadata; + } + + await Task.Delay(_pollingInterval, linkedSource.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + when (!cancellationToken.IsCancellationRequested + && timeoutSource.IsCancellationRequested) + { + throw new FileSentryPollingTimeoutException(fileId, _scanTimeout); + } + } + + public async Task DownloadAsync( + Guid fileId, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + EnsureFileId(fileId); + using var request = CreateRequest( + HttpMethod.Get, + $"{GetFilePath(fileId)}/download"); + HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + try + { + await EnsureStatusAsync(response, HttpStatusCode.OK, cancellationToken) + .ConfigureAwait(false); + Stream content = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + return new ResponseOwnedStream(content, response); + } + catch + { + response.Dispose(); + throw; + } + } + + public async Task DeleteAsync( + Guid fileId, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + EnsureFileId(fileId); + using var request = CreateRequest(HttpMethod.Delete, GetFilePath(fileId)); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + await EnsureStatusAsync(response, HttpStatusCode.NoContent, cancellationToken) + .ConfigureAwait(false); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + private static bool IsTerminal(FileStatus status) => status is + FileStatus.Clean + or FileStatus.Infected + or FileStatus.ScanFailed + or FileStatus.Deleted; + + private static string GetFilePath(Guid fileId) => $"{FilesPath}/{fileId:D}"; + + private static void EnsureFileId(Guid fileId) + { + if (fileId == Guid.Empty) + { + throw new ArgumentException("The file ID must not be empty.", nameof(fileId)); + } + } + + private HttpRequestMessage CreateRequest(HttpMethod method, string relativePath) + { + var request = new HttpRequestMessage(method, new Uri(_baseAddress, relativePath)); + request.Headers.Add(ApiKeyHeaderName, _apiKey); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return request; + } + + private async Task EnsureStatusAsync( + HttpResponseMessage response, + HttpStatusCode expectedStatus, + CancellationToken cancellationToken) + { + if (response.StatusCode == expectedStatus) + { + return; + } + + throw await CreateApiExceptionAsync(response, cancellationToken).ConfigureAwait(false); + } + + private async Task CreateApiExceptionAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + ProblemDetailsWireModel? problem = null; + try + { + if (response.Content.Headers.ContentLength is not > MaximumProblemDetailsBytes) + { + byte[] body = await ReadBoundedAsync( + response.Content, + MaximumProblemDetailsBytes, + cancellationToken).ConfigureAwait(false); + if (body.Length > 0) + { + problem = JsonSerializer.Deserialize(body, JsonOptions); + } + } + } + catch (JsonException) + { + } + + return new FileSentryApiException( + response.StatusCode, + problem?.Status, + RedactCredential(problem?.Code), + RedactCredential(problem?.Title), + RedactCredential(problem?.Detail), + RedactCredential(problem?.Type), + RedactCredential(problem?.Instance)); + } + + private static async Task ReadBoundedAsync( + HttpContent content, + int maximumBytes, + CancellationToken cancellationToken) + { + await using Stream stream = await content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using var output = new MemoryStream(); + byte[] buffer = new byte[4096]; + while (output.Length <= maximumBytes) + { + int read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + return output.ToArray(); + } + + if (output.Length + read > maximumBytes) + { + return []; + } + + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken) + .ConfigureAwait(false); + } + + return []; + } + + private static async Task ReadRequiredJsonAsync( + HttpContent content, + CancellationToken cancellationToken) + { + try + { + T? value = await content.ReadFromJsonAsync(JsonOptions, cancellationToken) + .ConfigureAwait(false); + return value ?? throw UnexpectedResponse(); + } + catch (JsonException) + { + throw UnexpectedResponse(); + } + catch (NotSupportedException) + { + throw UnexpectedResponse(); + } + } + + private static FileUpload MapUpload(FileUploadWireModel model) + { + ValidateCommonResponse( + model.FileId, + model.OriginalFileName, + model.SizeBytes, + model.Sha256, + model.DetectedMediaType, + model.CreatedAtUtc); + return new FileUpload( + model.FileId, + model.OriginalFileName!, + model.SizeBytes, + model.Sha256!, + model.DetectedMediaType!, + ParseStatus(model.Status), + model.CreatedAtUtc); + } + + private static FileMetadata MapMetadata(FileMetadataWireModel model) + { + ValidateCommonResponse( + model.FileId, + model.OriginalFileName, + model.SizeBytes, + model.Sha256, + model.DetectedMediaType, + model.CreatedAtUtc); + if (model.UpdatedAtUtc == default) + { + throw UnexpectedResponse(); + } + + return new FileMetadata( + model.FileId, + model.OriginalFileName!, + model.SizeBytes, + model.Sha256!, + model.DetectedMediaType!, + ParseStatus(model.Status), + model.CreatedAtUtc, + model.UpdatedAtUtc); + } + + private static void ValidateCommonResponse( + Guid fileId, + string? originalFileName, + long sizeBytes, + string? sha256, + string? detectedMediaType, + DateTimeOffset createdAtUtc) + { + if (fileId == Guid.Empty + || originalFileName is null + || sizeBytes < 0 + || string.IsNullOrWhiteSpace(sha256) + || string.IsNullOrWhiteSpace(detectedMediaType) + || createdAtUtc == default) + { + throw UnexpectedResponse(); + } + } + + private static FileStatus ParseStatus(string? status) => status switch + { + nameof(FileStatus.PendingScan) => FileStatus.PendingScan, + nameof(FileStatus.Scanning) => FileStatus.Scanning, + nameof(FileStatus.Clean) => FileStatus.Clean, + nameof(FileStatus.Infected) => FileStatus.Infected, + nameof(FileStatus.ScanFailed) => FileStatus.ScanFailed, + nameof(FileStatus.Deleted) => FileStatus.Deleted, + _ => throw UnexpectedResponse("The API returned an unsupported file status.") + }; + + private string? RedactCredential(string? value) => string.IsNullOrEmpty(value) + ? value + : value.Replace(_apiKey, "[REDACTED]", StringComparison.Ordinal); + + private static FileSentryProtocolException UnexpectedResponse( + string message = "The FileSentry API returned a malformed or unexpected response.") => + new(message); + + private sealed record FileUploadWireModel( + Guid FileId, + string? OriginalFileName, + long SizeBytes, + string? Sha256, + string? DetectedMediaType, + string? Status, + DateTimeOffset CreatedAtUtc); + + private sealed record FileMetadataWireModel( + Guid FileId, + string? OriginalFileName, + long SizeBytes, + string? Sha256, + string? DetectedMediaType, + string? Status, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); + + private sealed record ProblemDetailsWireModel( + string? Type, + string? Title, + int? Status, + string? Detail, + string? Instance, + string? Code); +} diff --git a/src/FileSentry.Client/FileSentryClientOptions.cs b/src/FileSentry.Client/FileSentryClientOptions.cs new file mode 100644 index 0000000..2fbf006 --- /dev/null +++ b/src/FileSentry.Client/FileSentryClientOptions.cs @@ -0,0 +1,70 @@ +using System.Text; + +namespace FileSentry.Client; + +public sealed class FileSentryClientOptions +{ + public required Uri BaseAddress { get; init; } + + public required string ApiKey { get; init; } + + public TimeSpan PollingInterval { get; init; } = TimeSpan.FromSeconds(1); + + public TimeSpan ScanTimeout { get; init; } = TimeSpan.FromMinutes(5); + + internal ValidatedFileSentryClientOptions Validate() + { + ArgumentNullException.ThrowIfNull(BaseAddress); + + if (!BaseAddress.IsAbsoluteUri + || (BaseAddress.Scheme != Uri.UriSchemeHttp + && BaseAddress.Scheme != Uri.UriSchemeHttps) + || !string.IsNullOrEmpty(BaseAddress.UserInfo) + || !string.IsNullOrEmpty(BaseAddress.Query) + || !string.IsNullOrEmpty(BaseAddress.Fragment)) + { + throw new ArgumentException( + "The FileSentry base address must be an absolute HTTP or HTTPS URL without credentials, a query, or a fragment.", + nameof(BaseAddress)); + } + + if (string.IsNullOrWhiteSpace(ApiKey) + || Encoding.UTF8.GetByteCount(ApiKey) < 32 + || ApiKey.Length > 512 + || ApiKey.Any(character => character is < (char)0x21 or > (char)0x7e)) + { + throw new ArgumentException( + "The FileSentry API key must contain 32 to 512 printable ASCII characters.", + nameof(ApiKey)); + } + + if (PollingInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(PollingInterval), + "The polling interval must be greater than zero."); + } + + if (ScanTimeout <= TimeSpan.Zero || ScanTimeout < PollingInterval) + { + throw new ArgumentOutOfRangeException( + nameof(ScanTimeout), + "The scan timeout must be greater than or equal to the polling interval."); + } + + string normalizedBaseAddress = BaseAddress.AbsoluteUri.EndsWith("/", StringComparison.Ordinal) + ? BaseAddress.AbsoluteUri + : $"{BaseAddress.AbsoluteUri}/"; + return new ValidatedFileSentryClientOptions( + new Uri(normalizedBaseAddress, UriKind.Absolute), + ApiKey, + PollingInterval, + ScanTimeout); + } +} + +internal sealed record ValidatedFileSentryClientOptions( + Uri BaseAddress, + string ApiKey, + TimeSpan PollingInterval, + TimeSpan ScanTimeout); diff --git a/src/FileSentry.Client/FileSentryException.cs b/src/FileSentry.Client/FileSentryException.cs new file mode 100644 index 0000000..d1c1d87 --- /dev/null +++ b/src/FileSentry.Client/FileSentryException.cs @@ -0,0 +1,3 @@ +namespace FileSentry.Client; + +public abstract class FileSentryException(string message) : Exception(message); diff --git a/src/FileSentry.Client/FileSentryPollingTimeoutException.cs b/src/FileSentry.Client/FileSentryPollingTimeoutException.cs new file mode 100644 index 0000000..1667c04 --- /dev/null +++ b/src/FileSentry.Client/FileSentryPollingTimeoutException.cs @@ -0,0 +1,12 @@ +namespace FileSentry.Client; + +public sealed class FileSentryPollingTimeoutException( + Guid fileId, + TimeSpan timeout) + : FileSentryException( + $"File {fileId} did not reach a terminal scan state within {timeout}.") +{ + public Guid FileId { get; } = fileId; + + public TimeSpan Timeout { get; } = timeout; +} diff --git a/src/FileSentry.Client/FileSentryProtocolException.cs b/src/FileSentry.Client/FileSentryProtocolException.cs new file mode 100644 index 0000000..52c7670 --- /dev/null +++ b/src/FileSentry.Client/FileSentryProtocolException.cs @@ -0,0 +1,3 @@ +namespace FileSentry.Client; + +public sealed class FileSentryProtocolException(string message) : FileSentryException(message); diff --git a/src/FileSentry.Client/FileStatus.cs b/src/FileSentry.Client/FileStatus.cs new file mode 100644 index 0000000..25067a9 --- /dev/null +++ b/src/FileSentry.Client/FileStatus.cs @@ -0,0 +1,11 @@ +namespace FileSentry.Client; + +public enum FileStatus +{ + PendingScan = 0, + Scanning = 1, + Clean = 2, + Infected = 3, + ScanFailed = 4, + Deleted = 5 +} diff --git a/src/FileSentry.Client/FileUpload.cs b/src/FileSentry.Client/FileUpload.cs new file mode 100644 index 0000000..ffb7d42 --- /dev/null +++ b/src/FileSentry.Client/FileUpload.cs @@ -0,0 +1,10 @@ +namespace FileSentry.Client; + +public sealed record FileUpload( + Guid FileId, + string OriginalFileName, + long SizeBytes, + string Sha256, + string DetectedMediaType, + FileStatus Status, + DateTimeOffset CreatedAtUtc); diff --git a/src/FileSentry.Client/Internal/NonDisposingReadStream.cs b/src/FileSentry.Client/Internal/NonDisposingReadStream.cs new file mode 100644 index 0000000..fb47e6d --- /dev/null +++ b/src/FileSentry.Client/Internal/NonDisposingReadStream.cs @@ -0,0 +1,57 @@ +namespace FileSentry.Client.Internal; + +internal sealed class NonDisposingReadStream(Stream inner) : Stream +{ + public override bool CanRead => inner.CanRead; + + public override bool CanSeek => inner.CanSeek; + + public override bool CanWrite => false; + + public override long Length => inner.Length; + + public override long Position + { + get => inner.Position; + set => inner.Position = value; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => + inner.Read(buffer, offset, count); + + public override int Read(Span buffer) => inner.Read(buffer); + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) => + inner.ReadAsync(buffer, cancellationToken); + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) => + inner.ReadAsync(buffer, offset, count, cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + } + + public override ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } +} diff --git a/src/FileSentry.Client/Internal/ResponseOwnedStream.cs b/src/FileSentry.Client/Internal/ResponseOwnedStream.cs new file mode 100644 index 0000000..fd9cba3 --- /dev/null +++ b/src/FileSentry.Client/Internal/ResponseOwnedStream.cs @@ -0,0 +1,87 @@ +namespace FileSentry.Client.Internal; + +internal sealed class ResponseOwnedStream( + Stream inner, + HttpResponseMessage response) : Stream +{ + private bool _disposed; + + public override bool CanRead => inner.CanRead; + + public override bool CanSeek => inner.CanSeek; + + public override bool CanWrite => false; + + public override long Length => inner.Length; + + public override long Position + { + get => inner.Position; + set => inner.Position = value; + } + + public override void Flush() => inner.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => + inner.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => + inner.Read(buffer, offset, count); + + public override int Read(Span buffer) => inner.Read(buffer); + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) => + inner.ReadAsync(buffer, cancellationToken); + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) => + inner.ReadAsync(buffer, offset, count, cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + try + { + inner.Dispose(); + } + finally + { + response.Dispose(); + } + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (!_disposed) + { + _disposed = true; + try + { + await inner.DisposeAsync().ConfigureAwait(false); + } + finally + { + response.Dispose(); + } + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/FileSentry.Client/README.md b/src/FileSentry.Client/README.md new file mode 100644 index 0000000..a72a15f --- /dev/null +++ b/src/FileSentry.Client/README.md @@ -0,0 +1,49 @@ +# FileSentry.Client + +`FileSentry.Client` is the .NET 10 client for the existing FileSentry `/api/v1/files` +service API. The package is in pre-publication development and is not yet available +from NuGet. + +```csharp +using FileSentry.Client; + +using var client = new FileSentryClient(new FileSentryClientOptions +{ + BaseAddress = new Uri("https://filesentry.example/"), + ApiKey = configuration["FileSentry:ApiKey"]!, + PollingInterval = TimeSpan.FromSeconds(1), + ScanTimeout = TimeSpan.FromMinutes(5) +}); + +await using Stream uploadContent = File.OpenRead("cv.pdf"); +FileUpload upload = await client.UploadAsync(uploadContent, "cv.pdf", cancellationToken); +FileMetadata result = await client.WaitForScanAsync(upload.FileId, cancellationToken); + +if (result.Status == FileStatus.Clean) +{ + await using Stream clean = await client.DownloadAsync( + upload.FileId, + cancellationToken); + // Process the stream without assuming the content is harmless. +} +``` + +The safe workflow is `Upload -> Wait for a terminal state -> continue only if +Clean -> Download`. `Infected`, `ScanFailed`, and `Deleted` are terminal but never +successful. An unknown state fails with `FileSentryProtocolException`. + +The SDK streams uploads and downloads and attaches `X-Api-Key` per request. Keep +the key in an external secret provider. Do not log it or embed it in source. An +injected `HttpClient` can be supplied through the alternate constructor; its +default headers are not modified and its lifetime remains owned by the caller. +Configure an injected client's primary handler with automatic redirects disabled +so a custom authentication header cannot be forwarded to another origin. The +options-only constructor does this by default. + +API ProblemDetails responses produce `FileSentryApiException`, with status, stable +code, title, detail, type, and instance where available. Polling timeout and invalid +wire responses use dedicated exception types. + +A FileSentry `Clean` result reduces risk; it does not guarantee that content is +harmless. The server remains authoritative for validation, scanning, ownership, +and download authorization. diff --git a/tests/FileSentry.IntegrationTests/ClientSdkIntegrationTests.cs b/tests/FileSentry.IntegrationTests/ClientSdkIntegrationTests.cs new file mode 100644 index 0000000..0c50f4b --- /dev/null +++ b/tests/FileSentry.IntegrationTests/ClientSdkIntegrationTests.cs @@ -0,0 +1,89 @@ +using FileSentry.Api.Infrastructure.Scanning; +using FileSentry.Client; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FileSentry.IntegrationTests; + +[Collection(AuthenticationApiCollection.Name)] +public sealed class ClientSdkIntegrationTests(AuthenticationApiFactory factory) +{ + [Fact] + public async Task SdkUpload_WaitsForClean_DownloadsByteIdentically_AndDeletes() + { + using WebApplicationFactory workerFactory = factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ScannerWorker:Enabled"] = "true", + ["ScannerWorker:PollingIntervalMilliseconds"] = "100" + })); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(); + }); + }); + using HttpClient httpClient = workerFactory.CreateClient( + new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost") + }); + using var client = new FileSentryClient(httpClient, new FileSentryClientOptions + { + BaseAddress = httpClient.BaseAddress!, + ApiKey = factory.ServiceApiKey, + PollingInterval = TimeSpan.FromMilliseconds(100), + ScanTimeout = TimeSpan.FromSeconds(10) + }); + byte[] expected = TestFileContent.Pdf(); + Guid? fileId = null; + + try + { + using var uploadContent = new MemoryStream(expected); + FileUpload upload = await client.UploadAsync(uploadContent, "sdk-clean.pdf"); + fileId = upload.FileId; + Assert.Equal(FileStatus.PendingScan, upload.Status); + + FileMetadata scanned = await client.WaitForScanAsync(upload.FileId); + Assert.Equal(FileStatus.Clean, scanned.Status); + + await using Stream download = await client.DownloadAsync(upload.FileId); + using var downloaded = new MemoryStream(); + await download.CopyToAsync(downloaded); + Assert.Equal(expected, downloaded.ToArray()); + + await client.DeleteAsync(upload.FileId); + fileId = null; + } + finally + { + if (fileId is Guid remainingFileId) + { + await client.DeleteAsync(remainingFileId); + } + } + } + + private sealed class AlwaysCleanClamAvClient : IClamAvClient + { + public async Task ScanAsync( + Stream content, + CancellationToken cancellationToken) + { + byte[] buffer = new byte[4096]; + while (await content.ReadAsync(buffer, cancellationToken) > 0) + { + } + + return ClamAvScanResult.Clean() with + { + ScannerVersion = "ClamAV SDK integration fake" + }; + } + } +} diff --git a/tests/FileSentry.IntegrationTests/FileSentry.IntegrationTests.csproj b/tests/FileSentry.IntegrationTests/FileSentry.IntegrationTests.csproj index 85b279e..1c45ff0 100644 --- a/tests/FileSentry.IntegrationTests/FileSentry.IntegrationTests.csproj +++ b/tests/FileSentry.IntegrationTests/FileSentry.IntegrationTests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/FileSentry.UnitTests/FileSentry.UnitTests.csproj b/tests/FileSentry.UnitTests/FileSentry.UnitTests.csproj index 269acc2..240c3fb 100644 --- a/tests/FileSentry.UnitTests/FileSentry.UnitTests.csproj +++ b/tests/FileSentry.UnitTests/FileSentry.UnitTests.csproj @@ -21,6 +21,7 @@ + diff --git a/tests/FileSentry.UnitTests/FileSentryClientTests.cs b/tests/FileSentry.UnitTests/FileSentryClientTests.cs new file mode 100644 index 0000000..0984d92 --- /dev/null +++ b/tests/FileSentry.UnitTests/FileSentryClientTests.cs @@ -0,0 +1,450 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using FileSentry.Client; + +namespace FileSentry.UnitTests; + +public sealed class FileSentryClientTests +{ + private const string ApiKey = "sdk-test-api-key-that-is-at-least-thirty-two-characters"; + private static readonly Guid FileId = Guid.Parse("de49c091-29a1-4f27-b6da-782bb3a62fa1"); + + [Fact] + public async Task UploadAsync_SendsPerRequestApiKeyAndStreamsMultipartWithoutOwningInput() + { + byte[] expectedContent = "%PDF-1.4\nstreamed\n%%EOF"u8.ToArray(); + var uploadStream = new AsyncOnlyReadStream(expectedContent); + var handler = new StubHttpMessageHandler(async (request, cancellationToken) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal( + "https://filesentry.test/root/api/v1/files", + request.RequestUri?.AbsoluteUri); + Assert.Equal(ApiKey, Assert.Single(request.Headers.GetValues("X-Api-Key"))); + Assert.Equal(0, uploadStream.BytesRead); + Assert.StartsWith( + "multipart/form-data", + request.Content?.Headers.ContentType?.MediaType, + StringComparison.Ordinal); + var multipartContent = Assert.IsType(request.Content); + HttpContent filePart = Assert.Single(multipartContent); + Assert.Equal("file", filePart.Headers.ContentDisposition?.Name); + Assert.Equal("resume.pdf", filePart.Headers.ContentDisposition?.FileName); + byte[] multipart = await request.Content!.ReadAsByteArrayAsync(cancellationToken); + Assert.True(multipart.AsSpan().IndexOf(expectedContent) >= 0); + return JsonResponse( + HttpStatusCode.Accepted, + UploadJson(FileStatus.PendingScan, expectedContent.Length)); + }); + using HttpClient httpClient = new(handler); + using var client = CreateClient(httpClient); + + FileUpload upload = await client.UploadAsync(uploadStream, "resume.pdf"); + + Assert.Equal(FileId, upload.FileId); + Assert.Equal("resume.pdf", upload.OriginalFileName); + Assert.Equal(expectedContent.Length, upload.SizeBytes); + Assert.Equal(FileStatus.PendingScan, upload.Status); + Assert.True(uploadStream.BytesRead > 0); + Assert.False(uploadStream.IsDisposed); + Assert.False(httpClient.DefaultRequestHeaders.Contains("X-Api-Key")); + } + + [Fact] + public async Task MetadataAndList_UseTypedContractModels() + { + var responses = new Queue( + [ + JsonResponse(HttpStatusCode.OK, MetadataJson(FileStatus.Scanning)), + JsonResponse( + HttpStatusCode.OK, + $"[{MetadataJson(FileStatus.Clean)}]") + ]); + var handler = new StubHttpMessageHandler((request, _) => + { + Assert.Equal(ApiKey, Assert.Single(request.Headers.GetValues("X-Api-Key"))); + return Task.FromResult(responses.Dequeue()); + }); + using HttpClient httpClient = new(handler); + using var client = CreateClient(httpClient); + + FileMetadata metadata = await client.GetFileAsync(FileId); + IReadOnlyList files = await client.ListFilesAsync(); + + Assert.Equal(FileStatus.Scanning, metadata.Status); + FileMetadata listed = Assert.Single(files); + Assert.Equal(FileId, listed.FileId); + Assert.Equal(FileStatus.Clean, listed.Status); + Assert.Equal("application/pdf", listed.DetectedMediaType); + } + + [Fact] + public async Task DownloadAsync_ReturnsLazyResponseOwnedStream() + { + byte[] expected = "clean download bytes"u8.ToArray(); + var responseStream = new TrackingReadStream(expected); + var handler = new StubHttpMessageHandler((request, _) => + { + Assert.EndsWith($"/{FileId:D}/download", request.RequestUri?.AbsolutePath); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(responseStream) + }); + }); + using HttpClient httpClient = new(handler); + using var client = CreateClient(httpClient); + + Stream download = await client.DownloadAsync(FileId); + Assert.Equal(0, responseStream.BytesRead); + using var output = new MemoryStream(); + await download.CopyToAsync(output); + Assert.Equal(expected, output.ToArray()); + Assert.False(responseStream.IsDisposed); + + await download.DisposeAsync(); + + Assert.True(responseStream.IsDisposed); + } + + [Fact] + public async Task DeleteAsync_SendsDeleteAndRequiresNoContent() + { + var handler = new StubHttpMessageHandler((request, _) => + { + Assert.Equal(HttpMethod.Delete, request.Method); + Assert.EndsWith($"/{FileId:D}", request.RequestUri?.AbsolutePath); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + }); + using HttpClient httpClient = new(handler); + using var client = CreateClient(httpClient); + + await client.DeleteAsync(FileId); + } + + [Fact] + public async Task WaitForScanAsync_PollsActiveStatesUntilClean() + { + var statuses = new Queue( + [FileStatus.PendingScan, FileStatus.Scanning, FileStatus.Clean]); + int requests = 0; + var handler = new StubHttpMessageHandler((_, _) => + { + requests++; + return Task.FromResult(JsonResponse( + HttpStatusCode.OK, + MetadataJson(statuses.Dequeue()))); + }); + using HttpClient httpClient = new(handler); + using var client = CreateClient( + httpClient, + pollingInterval: TimeSpan.FromMilliseconds(1)); + + FileMetadata result = await client.WaitForScanAsync(FileId); + + Assert.Equal(FileStatus.Clean, result.Status); + Assert.Equal(3, requests); + } + + [Theory] + [InlineData(FileStatus.Infected)] + [InlineData(FileStatus.ScanFailed)] + public async Task WaitForScanAsync_StopsAtNonCleanTerminalState(FileStatus terminalStatus) + { + int requests = 0; + var handler = new StubHttpMessageHandler((_, _) => + { + requests++; + return Task.FromResult(JsonResponse( + HttpStatusCode.OK, + MetadataJson(terminalStatus))); + }); + using HttpClient httpClient = new(handler); + using var client = CreateClient(httpClient); + + FileMetadata result = await client.WaitForScanAsync(FileId); + + Assert.Equal(terminalStatus, result.Status); + Assert.NotEqual(FileStatus.Clean, result.Status); + Assert.Equal(1, requests); + } + + [Fact] + public async Task WaitForScanAsync_CallerCancellationStopsPolling() + { + var responseDisposed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new StubHttpMessageHandler((_, _) => Task.FromResult( + JsonResponse( + HttpStatusCode.OK, + MetadataJson(FileStatus.PendingScan), + () => responseDisposed.TrySetResult()))); + using HttpClient httpClient = new(handler); + using var client = CreateClient( + httpClient, + pollingInterval: TimeSpan.FromMinutes(1), + scanTimeout: TimeSpan.FromMinutes(2)); + using var cancellationSource = new CancellationTokenSource(); + + Task wait = client.WaitForScanAsync( + FileId, + cancellationSource.Token); + await responseDisposed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await cancellationSource.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => wait); + } + + [Fact] + public async Task ProblemDetails_MapsToTypedRedactedApiException() + { + string problemJson = JsonSerializer.Serialize(new + { + type = "https://errors.example/file-not-clean", + title = "File not clean", + status = 409, + detail = $"The credential {ApiKey} must not be reflected.", + instance = $"/api/v1/files/{FileId:D}/download", + code = "FILE_NOT_CLEAN" + }); + var handler = new StubHttpMessageHandler((_, _) => Task.FromResult( + JsonResponse(HttpStatusCode.Conflict, problemJson))); + using HttpClient httpClient = new(handler); + using var client = CreateClient(httpClient); + + FileSentryApiException exception = await Assert.ThrowsAsync( + () => client.DownloadAsync(FileId)); + + Assert.Equal(HttpStatusCode.Conflict, exception.StatusCode); + Assert.Equal(409, exception.ProblemStatus); + Assert.Equal("FILE_NOT_CLEAN", exception.Code); + Assert.Equal("File not clean", exception.Title); + Assert.Contains("[REDACTED]", exception.Detail, StringComparison.Ordinal); + Assert.DoesNotContain(ApiKey, exception.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain(ApiKey, exception.Detail, StringComparison.Ordinal); + } + + [Theory] + [InlineData("not-json")] + [InlineData("unknown-status")] + [InlineData("missing-file-id")] + public async Task MalformedOrUnexpectedSuccessResponse_FailsSafely(string scenario) + { + string responseBody = scenario switch + { + "not-json" => "{not-json", + "unknown-status" => MetadataJson(status: null, statusText: "FutureState"), + "missing-file-id" => MetadataJson(FileStatus.Clean).Replace( + FileId.ToString(), + Guid.Empty.ToString(), + StringComparison.Ordinal), + _ => throw new InvalidOperationException() + }; + var handler = new StubHttpMessageHandler((_, _) => Task.FromResult( + JsonResponse(HttpStatusCode.OK, responseBody))); + using HttpClient httpClient = new(handler); + using var client = CreateClient(httpClient); + + FileSentryProtocolException exception = + await Assert.ThrowsAsync( + () => client.GetFileAsync(FileId)); + + Assert.DoesNotContain(ApiKey, exception.ToString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("short")] + [InlineData("key-with-a-line-break-that-is-long-enough\nunsafe")] + public void InvalidApiKey_IsRejectedWithoutIncludingItInException(string apiKey) + { + var options = CreateOptions(apiKey: apiKey); + + ArgumentException exception = Assert.Throws( + () => new FileSentryClient(options)); + + Assert.DoesNotContain(apiKey, exception.ToString(), StringComparison.Ordinal); + } + + private static FileSentryClient CreateClient( + HttpClient httpClient, + TimeSpan? pollingInterval = null, + TimeSpan? scanTimeout = null) => + new(httpClient, CreateOptions(pollingInterval, scanTimeout)); + + private static FileSentryClientOptions CreateOptions( + TimeSpan? pollingInterval = null, + TimeSpan? scanTimeout = null, + string apiKey = ApiKey) => new() + { + BaseAddress = new Uri("https://filesentry.test/root"), + ApiKey = apiKey, + PollingInterval = pollingInterval ?? TimeSpan.FromMilliseconds(10), + ScanTimeout = scanTimeout ?? TimeSpan.FromSeconds(5) + }; + + private static HttpResponseMessage JsonResponse( + HttpStatusCode statusCode, + string json, + Action? onDispose = null) => new(statusCode) + { + Content = onDispose is null + ? new StringContent(json, Encoding.UTF8, "application/json") + : new CallbackStringContent(json, onDispose) + }; + + private static string UploadJson(FileStatus status, int sizeBytes) => JsonSerializer.Serialize(new + { + fileId = FileId, + originalFileName = "resume.pdf", + sizeBytes, + sha256 = new string('a', 64), + detectedMediaType = "application/pdf", + status = status.ToString(), + createdAtUtc = DateTimeOffset.Parse("2026-08-14T10:00:00Z") + }); + + private static string MetadataJson( + FileStatus? status, + string? statusText = null) => JsonSerializer.Serialize(new + { + fileId = FileId, + originalFileName = "resume.pdf", + sizeBytes = 24, + sha256 = new string('a', 64), + detectedMediaType = "application/pdf", + status = statusText ?? status?.ToString(), + createdAtUtc = DateTimeOffset.Parse("2026-08-14T10:00:00Z"), + updatedAtUtc = DateTimeOffset.Parse("2026-08-14T10:00:01Z") + }); + + private sealed class StubHttpMessageHandler( + Func> send) + : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => send(request, cancellationToken); + } + + private sealed class AsyncOnlyReadStream(byte[] content) : Stream + { + private int _position; + + public int BytesRead { get; private set; } + + public bool IsDisposed { get; private set; } + + public override bool CanRead => !IsDisposed; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => content.Length; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => + throw new InvalidOperationException("The SDK attempted a synchronous upload read."); + + public override int Read(Span buffer) => + throw new InvalidOperationException("The SDK attempted a synchronous upload read."); + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + int read = Math.Min(buffer.Length, content.Length - _position); + content.AsMemory(_position, read).CopyTo(buffer); + _position += read; + BytesRead += read; + return ValueTask.FromResult(read); + } + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + int read = Math.Min(count, content.Length - _position); + content.AsSpan(_position, read).CopyTo(buffer.AsSpan(offset, read)); + _position += read; + BytesRead += read; + return Task.FromResult(read); + } + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } + + private sealed class TrackingReadStream(byte[] content) : MemoryStream(content) + { + public int BytesRead { get; private set; } + + public bool IsDisposed { get; private set; } + + public override int Read(byte[] buffer, int offset, int count) + { + int read = base.Read(buffer, offset, count); + BytesRead += read; + return read; + } + + public override int Read(Span buffer) + { + int read = base.Read(buffer); + BytesRead += read; + return read; + } + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + int read = await base.ReadAsync(buffer, cancellationToken); + BytesRead += read; + return read; + } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } + + private sealed class CallbackStringContent(string content, Action onDispose) + : StringContent(content, Encoding.UTF8, "application/json") + { + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + onDispose(); + } + } + } +}