Skip to content
bitzorcas
中EN

Concept

Files finalization and content integrity

FinalizeUpload, metadata authority, state transitions, transactions, events, idempotency, and malware isolation.

Last updated

Finalize is the module’s key trust transition: it turns “the client says an object was uploaded” into “a business module may bind and download this asset.” The current implementation compares size, MIME, and a hash, but the authority of MIME and hash depends on the provider. Finalized is therefore not synonymous with security-verified content.

1. Request contract

Finalize an upload
POST /api/files/019c64e67fc87db8b7ca748f5442f91a/finalize HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"contentHash": "72f7d0f8c6d6c0b51f5f3e4fa76c76bc9f70a10fb149c010eb2f2e330df8f123",
"size": 48231,
"contentType": "application/pdf"
}

The generated endpoint maps the route FileId into the command. The message uses Update authorization and the FileHandshake timeout policy. That bounds the API request; it does not add retry, idempotency, or storage rollback.

2. Current execution path

Event publisherFileAssetIFileStorageCommand repositoryFinalize handlerClientEvent publisherFileAssetIFileStorageCommand repositoryFinalize handlerClientfileId + client metadataFindAsync(fileId)tenant-filtered FileAssetGetMetadataAsync(storageKey)length / type? / sha? / etag?Finalize(resolved metadata)success or typed conflictSaveAsync(asset)PublishAsync(FileAssetSummary)Finalized summary

The object was written before this path starts. A database transaction may roll back asset state and the event record, but it cannot automatically roll back the object bytes.

3. Metadata authority matrix

The handler’s actual merge rule is:

Actual FinalizeUpload metadata selection
// Client values begin as fallback values.
var verifiedHash = request.ContentHash;
var verifiedSize = request.Size;
var verifiedContentType = request.ContentType;
var metadata = await storage.GetMetadataAsync(asset.StorageKey, cancellationToken);
// Length always comes from the provider; nullable facts fall back to the client.
verifiedSize = metadata.ContentLength;
verifiedContentType = metadata.ContentType ?? request.ContentType;
verifiedHash = metadata.Sha256 ?? request.ContentHash;
var verifiedETag = metadata.ETag;
ProviderSizeContentTypeSHA-256ETagEffective risk
Localfile-system lengthnullnullnullClient supplies type/hash; HTTP presign is unavailable
S3-compatibleHEAD ContentLengthHEAD ContentTypex-amz-meta-sha256 onlyHEAD ETagUploader can supply user metadata; absent SHA falls back
Connector bridgestream LengthnullnullnullType and hash fully fall back to the client
UnavailableunsupportedunavailableunavailableunavailableHandler returns stable failure

ETag is not SHA-256, especially for multipart objects. The S3 adapter correctly avoids that equivalence.

4. Aggregate validation

FileAsset.ValidateObservedMetadata performs these checks in order:

  1. observed hash and content type must be non-empty;
  2. hash is compared only when the existing ContentHash is non-empty; PendingUpload begins empty;
  3. observed size must exactly equal the size declared during session creation;
  4. content type uses case-insensitive string equality;
  5. state, ContentHash, ETag, and FinalizedAt change only after all checks pass.

This prevents a failed comparison from partially advancing the aggregate. It does not prove that the observed hash was computed by a trusted party.

5. State-transition detail

MarkUploadedFinalizeaggregate internal stepsame aggregate callAlreadyFinalizedAlreadyDeleted

PendingUpload

Uploaded

Finalized

Deleted

Aggregate Finalize advances PendingUpload twice: it marks Uploaded, then Finalized. Calling FileAssetState.Finalize() directly on PendingUpload returns File.NotYetUploaded. That difference between the public state type and aggregate contract needs explicit tests.

6. Duplicate requests and concurrency

A second finalize returns File.AlreadyFinalized; it does not replay the first result. The message has no idempotency key, version column, or explicit optimistic-concurrency token.

Safely handling a finalize conflict
var response = await api.PostAsJsonAsync(
$"/api/files/{fileId}/finalize",
new { contentHash, size, contentType },
cancellationToken);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<FileAssetSummaryDto>(cancellationToken);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetailsDto>(cancellationToken);
// AlreadyFinalized says database state is complete. Read the authorized summary
// if the client must recover the first result; do not blindly repeat the mutation.
if (problem?.Code == "File.AlreadyFinalized")
return await fileAssets.GetAuthorizedSummaryAsync(fileId, cancellationToken);
throw new FileFinalizeException(problem?.Code ?? "File.Unknown");

If two requests read Pending concurrently, correctness depends on repository/transaction conflict behavior. Current state tests are sequential; there is no real-database concurrent-finalize contract.

7. Error semantics

ErrorTriggerState advances?
File.NotFound / repository errorNo visible asset in the tenantNo
File.MetadataVerificationUnavailableProvider cannot return metadataNo
File.ObjectNotFoundProvider reports missing objectNo
File.ObservedMetadataRequiredResolved hash/type is blankNo
File.HashMismatchExisting and observed hashes differNo
File.SizeMismatchObject length differs from declarationNo
File.ContentTypeMismatchObject type differs from declarationNo
File.AlreadyFinalizedDuplicate finalizeRemains Finalized
File.AlreadyDeletedFinalize after deletionRemains Deleted

The shared Result mapper creates Problem Details. Consumers should branch on the stable Code, never localized message text.

8. The real UploadExpiresAt boundary

Create persists clock.UtcNow.AddMinutes(30), but Finalize never reads UploadExpiresAt. An asset may finalize after its database session expiry if the bytes were written or the storage URL remains valid.

Target upload-expiry gate
public static class FileErrors
{
public static readonly Error UploadSessionExpired =
Error.Conflict(
FileErrorCodes.UploadSessionExpired,
"The upload session has expired; create a new session.");
}
// This is a recommended domain gate, not current source behavior.
if (asset.UploadExpiresAt is not null && clock.UtcNow >= asset.UploadExpiresAt)
{
return Result.Failure<FileAssetSummary>(FileErrors.UploadSessionExpired);
}

The object-storage expiry and database value also need one source of truth. The connector provider cannot currently accept a request-level TTL.

9. Target scanning and isolation flow

yesno

Upload to quarantine key

Trusted metadata + server SHA-256

Malware / DLP / format validation

All checks pass?

Copy or mark into trusted zone

FileAsset Finalized

Quarantined / Rejected
retain evidence, then clean

The current state machine has no Quarantined, Scanning, or Rejected state. An asynchronous scanner cannot coexist safely with early Finalized state because business modules may then download unreviewed bytes.

10. Events and transactions

After repository.SaveAsync, the handler calls fileFinalizedPublisher.PublishAsync(asset.ToSummary()). With a correctly configured CAP Outbox, the database write and event record participate in the transaction pipeline. The external object does not.

Failure pointDatabaseObjectEventRecovery
Metadata read failsPendingMay existNoneRepair storage and retry
Aggregate rejects metadataPendingExists but mismatchedNoneQuarantine/delete object
Save failsRolled backExistsNoneIdempotent retry or orphan cleanup
Publish fails in transactionRolled backExistsNoneOutbox/command retry
Process dies after commitFinalizedExistsOutbox-dependentOutbox redelivery

11. Target regression test

Reject a provider without a trusted hash
[Fact]
public async Task Finalize_should_not_trust_client_hash_when_provider_has_no_sha256()
{
// Return trusted length/type but deliberately omit a provider-computed SHA-256.
storage.GetMetadataAsync(asset.StorageKey, Arg.Any<CancellationToken>())
.Returns(new FileObjectMetadata(
asset.StorageKey,
ContentLength: asset.Size,
ContentType: asset.ContentType,
ETag: "etag-1",
Sha256: null,
LastModified: clock.UtcNow));
var result = await handler.Handle(
new FinalizeUploadCommand(asset.FileId, "client-controlled", asset.Size, asset.ContentType),
CancellationToken.None);
// Target behavior: fail closed instead of falling back to the request hash.
result.IsFailure.ShouldBeTrue();
result.Error.Code.ShouldBe(FileErrorCodes.MetadataVerificationUnavailable);
}

This test describes a production target. Current code accepts the client hash, so the contract and implementation must change first.

12. Source and verification

Terminal window
# Run current state-machine and handler tests.
dotnet test tests/BitzOrcas.Unit.Tests \
--filter FullyQualifiedName~FileAssetFinalizeTests
dotnet test tests/BitzOrcas.Application.Tests \
--filter FullyQualifiedName~FinalizeUpload
# Prove hash fallback and the unconsumed expiry field.
rg -n "metadata.Sha256 \?\?|UploadExpiresAt" src/Platform/Files -g '*.cs'
# Search for scanning and quarantine states; expect no current hit.
rg -n "Malware|Virus|Quarantine|Scanning|Rejected" src/Platform/Files -g '*.cs'

Previous: Upload sessions · Next: Download authorization

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%