| D-M3-04-1 |
2026-10-20 |
A transient failure of the pass (PING failed, connection error, timeout, a reply the parser refuses, an object store error) returns no error to River: in a short unit of work that checks again that the file awaits the pass, the handler inserts the next files.scan with Round + 1 at now + NextScanDelay(Round, file.scan.health_interval, file.scan.retry_backoff_cap) and returns nil. The delay doubles from file.scan.health_interval per round, never exceeds the cap and saturates for any round (no overflow; the round stops growing at the largest int64). Only a failed database unit returns its error to River. |
12 5.3 allows no upper limit of attempts while the file is not deleted; River's attempt limit would end the retries (brief design note 7). |
| D-M3-04-2 |
2026-10-20 |
files.scan is registered without River job timeout: platform/jobs.HandlerOptions gains Timeout (zero: River's default, negative: none, mapped to River's per-worker timeout). The pass bounds itself with file.scan.timeout; PING uses file.scan.health_interval as deadline. |
River's default job timeout (1 minute) is shorter than file.scan.timeout and would cut the unit of work that follows the scan. |
| D-M3-04-3 |
2026-10-20 |
Outcome precedence (first row wins): size different, SHA-256 different, detected type denied (failed with size_mismatch, checksum_mismatch, file_type_denied, objects deleted), then file.scan.mode = off (skipped), then the clamd verdict: clean; error with scan_limits_exceeded; infected with malware_detected; error with scan_stream_limit_exceeded; error with scan_engine_error. The three failed rows decide also when the scan failed transiently, and a FOUND for such a file writes the malware audit entry as well. |
Size, digest and type are known from the complete stream; file_type_denied is the reason of the early 415; the audit on failed files closes the gap that a wrong checksum could hide a detection. |
| D-M3-04-4 |
2026-10-20 |
A clamd reply the parser refuses (ErrClamdProtocol) leaves the file pending and retries; a connection that clamd closes without any byte is a transport error, not a protocol error. The reply is bounded by file.scan.chunk_bytes. |
A broken reply says nothing about the file; failing it would block clean files. A closed connection is how a vanished clamd looks behind a socket. |
| D-M3-04-5 |
2026-10-20 |
scan_engine = 'clamd' and scanned_at are set exactly when clamd's verdict decided the file; both stay NULL for skipped and failed. files.sha256 and files.size_bytes take the computed values when the file becomes complete. |
The columns record who checked the bytes; a skipped or failed file was not decided by clamd. |
| D-M3-04-6 |
2026-10-20 |
The detected type is stored without parameters, trimmed and in lower case (domain.MediaType); it is denied when it equals an entry of file.denied_mime_types treated the same way (domain.TypeDenied). The early check of M3-03 keeps its own matcher in app/create.go, because it also matches file extensions; both compare media types by the same rule. An empty head is application/octet-stream. The detector reads only the buffered head of file.sniff_bytes and never unpacks an archive; mimetype.NewDetector sets the process-wide read limit of the library to file.sniff_bytes, because its default of 4 KiB would miss Office markers further in (code review finding). |
12 6.1 and SEC-UP-2, SEC-UP-7; the library calls an empty input text/plain. |
| D-M3-04-7 |
2026-10-20 |
Source of the pass: Head(object_key) first; an existing object is read once with Get (an earlier attempt joined it; objects are never overwritten). A missing object with a storage_upload_id (S3) is first completed by the pass with CompleteMultipart over the confirmed parts, because merged M3-03 leaves the multipart upload open at completion (D-M3-03-2); otherwise the parts uploads/<file_id>/<n> are read in order and joined into object_key through a pipe from the same stream. The source is limited to the declared size plus one byte; the scanner gets the tee, and whatever it leaves is drained. |
Brief design note 4 with the merged M3-03 layout: the file system pass reads every byte once (D-FI-04 holds); S3 joins server side. |
| D-M3-04-8 |
2026-10-20 |
The result unit of work locks the session row and then the files row FOR UPDATE, the order of the completion and the abort of M3-03, checks again that the file awaits the pass and otherwise changes nothing; a run that lost the race or found the file deleted removes only the object it joined itself, and only when the current state is not deliverable. Objects of failed and blocked files and the parts are deleted after the commit; a failed delete is logged and left to M3-08. |
03 6.3 asks to keep the order M3-03 uses; the losing run must not remove the object of a clean file, and must not leave the object of a blocked or deleted one. |
| D-M3-04-9 |
2026-10-20 |
Compose: clamd listens only on /run/clamav/clamd.sock in the named volume clamav-socket (mounted in clamav, read-only in worker, in no other service), LocalSocketMode 666, no TCPSocket, no ports and no expose; infra/compose/clamav/clamd.conf (from the image's own configuration of the pinned version) replaces the image's file read-only. The volume is created owned by root, so the service's entrypoint runs install -d -o clamav -g clamav -m 0755 /run/clamav before the image's /init. The health check pings over the socket (clamdscan --ping 1, which reads LocalSocket from the configuration), not over TCP. |
19 SEC-TLS-5; tried against the pinned image: without the entrypoint step clamd cannot bind the socket, and a process of another user connects over the read-only mount. |
| D-M3-04-10 |
2026-10-20 |
The health monitor keeps the scanner state (unknown, ok, unreachable, off) per worker process in memory and records bothy.file.scan.queue on every round; it runs as the start step scan_health after the event consumers and ends in the jobs phase of the shutdown. It never changes readiness. With mode off it logs one Warn at start and sends no PING. |
12 5.3 and brief design note 11; M7-09 reads files.ScannerHealth for the status component. |
| D-M3-04-11 |
2026-10-20 |
bothy.file.scan.duration measures the whole pass (read, join, scan, result) per outcome with the long-duration buckets; bothy.file.scan counts final outcomes only (failed files and retries are not counted). |
The pass and the scan are one stream; BothyMalwareScanFailing keeps its meaning. |
| D-M3-04-12 |
2026-10-20 |
Test clamd (adapters/clamd/clamdtest): the pinned image runs clamd --foreground with a test configuration (AlertExceedsMax, small explicit limits, a Unix socket and a TCP listener inside the container published on 127.0.0.1 only) and a database of one hash signature for the EICAR string computed at run time (Local.Test.EICAR, reported with .UNOFFICIAL). Instead of an unexported dialer option, clamdtest offers the loopback TCP port on a Unix socket of the host through a small relay that resolves the mapped port at every connection, so the production client (unix only) and the whole server through internal/wire talk to the real clamd unchanged. Containers that a test stops and starts run without AutoRemove and are removed by Terminate. |
VM-based Docker runtimes do not pass Unix sockets of bind mounts to the host; the full signature database needs minutes and gigabytes and must not be downloaded in tests (RK-52); the relay keeps every TCP code path out of production. |
| D-M3-04-13 |
2026-10-20 |
AuditSignature lower-cases the ClamAV name, replaces every character outside [a-z0-9_.:-] by _, prefixes x when the result does not start with a letter or digit, gives unknown for an empty name and cuts to 64 bytes, the code bound of audit_details.v1; the bound already holds a hex SHA-256, so the audit module is unchanged. |
SEC-AUD-5 allows only these characters in codes; ClamAV names contain upper case. |
| D-M3-04-14 |
2026-10-20 |
Merged names win over the brief (D-W-100): the type detector is Detector in the existing adapters/mimetype (the only package with the vendor mimetype) instead of a new adapters/sniff; the scan repository is app.ScanRepository, implemented by the M3-03 repository; bothy config check reports a configuration problem with exit code 1 (the merged command), bothy serve with 2. The rulings stand in a section per ticket like M3-03, because this base has no section 10 form. |
One adapter per vendor; the command's exit codes are fixed by M1-07. |
| D-M3-04-15 |
2026-10-20 |
Start checks scan_stream_limit (with mode required, file.max_upload_bytes at most file.scan.clamd_stream_max_bytes) and clamd_address_unix (unix:// plus an absolute, clean path, the rule of clamd.SocketPath); both messages name keys only. file.scan.pending_alert_after stays out of the schema until M7-09. |
12 5.4, 19 SEC-TLS-5, 02 13 item 5 (a key enters with its first user). |
| D-M3-04-16 |
2026-10-20 |
Tests: the race, deletion and health cases run as unit tests over fakes in internal/files/app (a fake unit of work that serializes units like row locks, and a test subscriber of files.FileFinalized in its own registry, because the registry of a harness server is sealed); the real-clamd cases run in adapters/clamd and in test/scan through the harness in role all (River picks files.scan from the queue files). Verification ran as targeted local tests: build-verify.yml is not on this base and the task forbids the push. |
The brief lists these cases as integration tests; the harness offers no hook into the transaction registry. |
| D-M3-04-17 |
2026-10-20 |
Two gates that M3-04 left red on build-verify (integration fix INT-W48/2) are fixed without a change of behaviour: the test signature of clamdtest is Local.Test.EICAR (was Bothy.Test.EICAR; the tests compare local.test.eicar), and the three net.Dialer of the clamd client and its test helper carry a nosemgrep: bothy-go-egress-only marker with the reason. |
tools/ci/tests/test_product_name_places.py allows the working name only at the kinds of 02 6, and a capitalized Bothy. prefix is none of them; the clamd client dials only the local Unix socket (clamd_address_unix) and the test helper only the test container, so the egress checks of SEC-NET-1 do not apply, the same reason as the backup health check. |
| D-M3-04-18 |
2026-10-22 |
Port onto main from the ticket branch (own commits 8d7ac90..6a1602a after the merge of M3-03, which is on main as #223, plus the integration fix INT-W48/2 as D-M3-04-17): the German chapter edits go into the English chapters 12, 02 and 06 and the English sequence diagram seq-7-file-upload; config keys, code comments and the module README name the English chapter files; the changelog and the ruling log take only this ticket's lines, as the fragments services/server/changelog.d/M3-04.md and docs/decisions.d/M3-04.md (PL/24); config-schema.json, the configuration reference, the REST layer, the clients and the diagram are generated again. |
The PR lane ports only the ticket; English documents on main win. |
| D-M3-04-19 |
2026-10-22 |
The fake clamd of clamdtest records a session and ends its count of open connections before it writes the reply, so a client that has its reply always sees its session in Sessions and the next connection of its own scan slot never overlaps it in MaxActive; a connection without a reply is recorded after it closes. TestScanMaxConcurrent waits for the stalled first scan with testkit.Until instead of a sleep, test/scan waits with testkit.Patience, and TestScanClamdStoppedStaysPending checks the retry delays by bounds that do not depend on the speed of the host (the gap of two scheduled_at values at least the delay, a round due at most the delay after its insert). |
TestScanMaxConcurrent failed on CI and locally in 6 of 300 runs ("2 sessions, want 3"): the fake appended the session after closing the connection, which the client may outrun; the old lower bound of the delay check failed whenever the retry unit took longer than 200 ms. |
| D-M3-04-20 |
2026-10-22 |
In TestRealClamdVerdicts the stream limit case repeats a scan whose reply was lost as a transport error, bounded by testkit.Patience; a protocol error or any verdict other than scan_stream_limit_exceeded fails at once. |
clamd answers the stream limit and closes with the rest of the stream unread, which resets the TCP leg of the test relay (D-M3-04-12) and can drop the reply (1 of 40 local runs under parallel load); production reaches clamd over its Unix socket only, and a lost reply there is a transient failure that the pass retries (D-M3-04-1). |
| D-M3-04-21 |
2026-10-22 |
The scan tests bound every wait by testkit (Until, Context, Deadline), as the gate bothy-go-test-wall-clock-wait requires, and the test clamd container takes a random name suffix (crypto/rand) instead of the clock. |
Two test processes that start together got the same name from the clock and the second failed to create its container; fixed waits fail correct changes on a loaded host. |