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();
+ }
+ }
+ }
+}