Appearance
The scan contract
The engine sits behind IFileScanner, in GrydFiles.Application. Swapping ClamAV for GuardDuty or for an ICAP appliance is a type argument at composition time and nothing else — no file of the application layer and no line of the scan job names an engine.
csharp
builder.Services.AddGrydObjectStorage(builder.Configuration);
builder.Services.AddGrydFiles(builder.Configuration);
builder.Services.AddGrydFilesClamAv(builder.Configuration);ClamAV has its own registration package, and AddGrydFilesClamAv is the whole of it. Any other engine is handed to the builder: AddGrydFiles(configuration, files => files.UseScanner<MyScanner>()). The two are alternatives, because AddGrydFilesClamAv replaces what UseScanner<T>() registered. With neither, the host refuses to start: a module with no engine would keep every upload in quarantine and look healthy doing it.
UseScanner<T>() is the only method on the builder. There is deliberately no default profile (D12): every upload names its profile, so a default would never have a moment to act — only a moment to apply the wrong ceiling and the wrong retention in silence.
The scanner is a layer, not the defence
ClamAV is a signature engine. It recognises what is already catalogued — a known malicious Office macro, a PDF carrying known JavaScript, an embedded executable, content inside an archive it unpacks and looks into. It does not recognise a new threat, it does no behavioural analysis, and it says nothing at all about whether content is appropriate.
Everything around it therefore keeps working and is not replaced by it:
| Layer | What it stops |
|---|---|
| The profile's allowed-type list | A format the profile never accepts, whatever the bytes turn out to be |
| The content-signature check | A .exe renamed .pdf — extension and declared type are the client's word |
| The per-profile size ceiling | Content larger than the profile was sized for, checked twice |
| The short-lived presigned URL | A link that keeps working after being pasted somewhere |
A green scan is one of five conditions for release, not the release itself.
Two operations, and neither of them decides anything
ScanAsync(Stream, CancellationToken) passes the content through the engine and reports the answer. It takes a Stream, which is the decision that matters: the object is pushed to the engine as it is read, so the file is never materialised on the scan node — not in a temporary file, not as a complete buffer in memory.
CheckHealthAsync(CancellationToken) asks the engine how it is. It is the module's single source of scanner health: the status route and the observability metrics read this and nothing else, because two health checks are two opinions that disagree on the afternoon it matters.
Neither moves a record. The scanner reports; the scan job interprets and decides. An engine that could move a file would be an engine that could release one.
Five outcomes, and only one of them is about the file
FileScanResult carries the outcome, the engine, the signature database version and the engine's reply kept exactly as it came.
| Outcome | The engine said | What it means |
|---|---|---|
Clean | stream: OK | Nothing catalogued was found |
Infected | stream: <name> FOUND | A verdict about the file. The name reaches scanSignature |
Error | ... ERROR, or no connection at all | The infrastructure failed |
Timeout | nothing, inside TimeoutSeconds | The engine did not answer |
SizeLimitExceeded | INSTREAM size limit exceeded | The operator's StreamMaxLength is below the profile ceiling |
Only the second row says anything about the content. The last three describe the engine or the operator's configuration, and none of them may ever release a file or condemn one — collapsing them into a boolean is the single change that would turn a daemon outage into a release.
SizeLimitExceeded is worth keeping separate from Error for a practical reason: it is fixed by editing a configuration file, not by looking at the file that tripped it. See docs/modules/files/clamd-configuration.md.
A failure never arrives as an exception. A daemon that is down is an Error result, because the job has to record an attempt that happened and failed, not conclude that no pass took place.
The database age is the signal nothing else shows
FileScannerHealth reports availability, the engine, the database version and the date that database was published — from which the age follows.
The age is the reason the type exists. A daemon whose database stopped updating keeps answering PING with PONG and keeps scanning happily, so every liveness signal stays green while the engine quietly stops recognising anything catalogued this week. Only the date shows it.
When the engine cannot be reached, the health still names the engine and reports the database version as unknown. That is a sentinel and not a blank, because a scan attempt row requires a database version to exist, and an attempt against a daemon that never answered is precisely the row an incident goes looking for. An unknown age is not a fresh one: a caller that cannot establish the age treats the engine as unfit.
The decisions behind this page are in ADR 0010; the contract is in the specification.