NAME

ClamAV::Clamd - talk to the clamd daemon

SYNOPSIS

use ClamAV::Clamd;

my $clamd = ClamAV::Clamd->new(
    socket => '/var/run/clamav/clamd.ctl',
);

$clamd->ping or die $clamd->error;

my $v = $clamd->scan_path('/var/spool/uploads/xyz');

if    ($v->is_clean)       { accept_it()  }
elsif ($v->is_infected)    { reject("found " . $v->signature) }
elsif ($v->is_unscannable) { reject("not scanned: " . $v->reason) }
else                       { reject("scanner unavailable: " . $v->error) }

DESCRIPTION

A client for the clamd daemon's socket protocol. It does not link libclamav and has no dependencies: clamd holds the signature engine - around 1.6 GB of it - and this asks it questions.

Scanning by descriptor or by bytes, blocking or on an event loop, with a four-state verdict and a C ABI for other XS distributions.

RECOMMENDED CLAMD.CONF

This is the configuration this module was developed and measured against. The three Alert settings are the important part - without them clamd cannot tell anyone it declined to scan something.

LocalSocket /run/clamav/clamd.ctl
LocalSocketMode 660

# Turn silently-skipped scans into reportable detections.
AlertExceedsMax       yes
AlertEncryptedArchive yes
AlertEncryptedDoc     yes

LocalSocket rather than TCPSocket where you can: descriptor passing needs a UNIX socket, and it is what lets clamd scan a file it has no permission to open by name.

If you must use TCP, raise StreamMaxLength to whatever your largest acceptable upload is. Over that ceiling clamd answers INSTREAM size limit exceeded. ERROR and closes - which this module reports as unscannable and never as clean, but it is a scan you did not get.

Concurrency

MaxThreads defaults to 10 and clamd queues silently past it rather than refusing. Measured here, 30 concurrent scans put p95 latency at about 7x the unloaded figure and nothing was rejected - so a burst of uploads becomes a burst of slow requests, not a burst of errors.

METHODS

new

ClamAV::Clamd->new(socket => '/var/run/clamav/clamd.ctl');
ClamAV::Clamd->new(host => '127.0.0.1', port => 3310);

Takes socket, or host and port, plus:

connect_timeout - seconds, default 5
reply_timeout - seconds, default 30
reply_max - ceiling on a single reply, default 1 MiB
frame - 'z' (default) or 'n'
chunk - INSTREAM chunk size, default 64 KiB
max_size - refuse anything larger without scanning; off by default

Croaks on a configuration that cannot work: no address, both addresses, a non-positive timeout, an unknown framing, or a UNIX socket path too long for the platform's sockaddr_un.

That last one croaks rather than truncating because the kernel copies a fixed-size array, so an over-long path is silently shortened - and a shortened path connects to a different socket than the one asked for. Believing an answer from an unidentified peer is worse than not getting one.

Nothing connects here. The address is stored, and each command makes and closes its own connection.

ping

True if clamd answered PONG, undef otherwise with "error" set.

version

clamd's version string, or undef.

stats

The STATS reply, or undef.

reload

Asks clamd to reload its signature database. True if it accepted.

Reloading is normally the operator's business, and a client that calls this on a timer is picking a fight with whatever else manages that daemon.

scan_fd

$clamd->scan_fd($filehandle);
$clamd->scan_fd($descriptor_number);

Scans an open file by handing clamd the descriptor itself. Takes a Perl filehandle or a raw descriptor number. Returns a "VERDICTS" object.

The descriptor is borrowed. It is not closed, and its file position is not moved - clamd scans the whole file wherever the descriptor happens to be positioned, so there is nothing to correct and correcting it would change state the caller owns.

Only regular files are accepted. A pipe, a socket or a directory is refused with ERR_NOTREG, because a scan of one cannot mean what the caller thinks it means.

Requires a UNIX socket. See "WHY THE DESCRIPTOR AND NOT THE PATH".

scan_path

$clamd->scan_path('/var/spool/uploads/xyz');

Opens the file and scans its descriptor, returning a "VERDICTS" object. The file is opened here, by this process, and the descriptor is what travels.

This is deliberately not clamd's own SCAN command, which hands clamd a path for clamd to resolve - and which therefore requires clamd to have permission on it.

scan

$clamd->scan($bytes);

Scans bytes that were never a file - a small upload held in memory, a decoded attachment, anything already in a scalar. Returns a "VERDICTS" object.

The bytes are sent from where they are, with no intermediate copy. They must be bytes: a string with wide characters is refused rather than guessed at, because guessing an encoding means scanning something the caller never stored.

Unlike "scan_fd", this works over TCP and on every platform - there is no descriptor involved.

transport

'fildes' or 'instream': which transport produced the last scan, or undef if none has run.

Worth watching. A deployment that quietly fell back to instream is sending every scanned file across the socket in full, and that is the kind of thing better read off a field than inferred from a latency graph.

SCANNING WITHOUT BLOCKING

A scan is a round trip to a daemon that may have queued the request behind a dozen others. Inside an event-driven server, spending that in a blocking read stalls every other connection the worker owns - which is the whole reason this module talks to clamd rather than linking libclamav.

my $scan = $clamd->start_scan($path, 'path');

# ... register $scan->fd with whatever loop you have, waiting for
# $scan->want ('read' or 'write'), and on readiness:

if ($scan->step) {
    my $v = $scan->verdict;
}

This module owns no event loop and depends on none. There is no adapter to write and no framework to adopt: a descriptor, a readiness, and a step function are enough for a bare select loop, and enough for anything larger to wrap in its own future type in about twenty lines.

start_scan($what, $kind)

$kind is 'bytes' (the default), 'path', or 'fd'. Returns a ClamAV::Clamd::Scan.

The handle keeps a reference to whatever it reads from, so a scan outlives the filehandle or scalar it was given.

The scan handle

fd

The descriptor to watch. Undef once finished - there is nothing left to watch, and a loop still watching a closed descriptor will eventually dispatch on somebody else's connection.

want

'read', 'write', or undef when finished.

step

Advance as far as possible without blocking. True when finished.

is_done / verdict

verdict is undef until the scan finishes, and thereafter always returns the same verdict.

cancel

Abandon a scan in flight. The connection is closed, never kept: clamd is still going to answer, and a connection carrying an unread verdict would hand that verdict to whichever scan picked it up next.

Two things the caller owns

The deadline only advances when you step. Without a timer this module cannot wake anybody. reply_timeout is checked on every step, so a scan that is being driven will time out - but a driver that stops calling step never learns that it should have. If your loop has timers, set one.

Concurrency is yours to bound. clamd's MaxThreads defaults to 10 and it queues silently past that rather than refusing: measured here, 30 concurrent scans put p95 latency at about 7x the unloaded figure and nothing was rejected. A cap belongs where the request context is, which is your code and not this module.

The blocking methods are this same machine

"scan", "scan_fd" and "scan_path" run the identical state machine under poll. There is deliberately no second implementation of the protocol in this distribution: a bug fixed on one path is fixed on both, and the non-blocking path cannot rot from disuse because every blocking call exercises it.

VERDICTS

Every scan returns a ClamAV::Clamd::Verdict, including one that never reached clamd. Returning undef on a timeout would mean

if ($clamd->scan($file)->is_clean) { ... }

dies on the one path where it matters most, so there is always something to ask.

The four states

clean

Scanned in full, nothing found.

infected

Scanned, and this signature matched.

unscannable

Not scanned, or not scanned completely. Over a size ceiling, nested past MaxRecursion, too many members, or an encrypted archive clamd could not open.

error

No usable answer at all: clamd unreachable, a timeout, a reply in no recognised shape.

There are four rather than two because unscannable must never collapse into clean. A boolean API reports "clean" for precisely the inputs an attacker constructs - a 2 KB nested zip, a password-protected archive, anything over the size ceiling - and it passes every test anybody writes for it, because nobody writes the test where the answer is "I did not look".

error must not collapse into clean either. A scanner that is down and reports clean is the vulnerability.

Methods

state, is_clean, is_infected, is_unscannable, is_error, signature, reason, transport, error, raw.

is_clean is true for exactly one of the four states. A caller who wants to accept an upload writes one short safe thing; a caller who wants to reject one has more to think about, which is the right way round.

reason says why something was unscannable: MaxFileSize, MaxRecursion, MaxFiles, StreamMaxLength, Encrypted, or max_size for the client's own ceiling.

In boolean and string context

A verdict is true only when it is clean, and stringifies to its state:

if ($clamd->scan($bytes)) { accept() }        # accepts only clean
warn "upload was $v";                         # "unscannable"

An object is otherwise always true, which would make that first line accept every infected file there is. That is the most dangerous plausible misuse of this API, so it is wired to be correct instead.

The signature is remote input

signature is a string chosen, in effect, by whoever supplied the file: a file crafted to match a given signature decides what comes back. It is length-bounded, and it belongs in a log rather than in a response body.

WHY THE DESCRIPTOR AND NOT THE PATH

A web server writing an upload to a private spool directory owns those files; clamd runs as another user entirely. There are only two ways to bridge that, and one of them is bad:

Widen the permissions

Loosen the file, or the directory, or run clamd somewhere it can read a directory full of unscanned attacker-controlled bytes. Every version of this opens up exactly the wrong place.

Pass the descriptor

The receiving process gets an open file description with access already granted. It never resolves a name, so it needs no permission on one.

This module does the second. The difference is demonstrable: given a file whose path has been made unreadable, clamd's own path-based command answers

File path check failure: Permission denied. ERROR

while scan_fd on a descriptor opened before the path was closed off returns the detection.

Descriptor passing needs a UNIX socket and a platform with SCM_RIGHTS. Over TCP, and on Windows, scan_fd and scan_path fall back to streaming the file instead, and "transport" says so.

They never send FILDES where it cannot work. clamd's answer to a FILDES it cannot honour is not an error - it is silence, with the connection held open, so the alternative to knowing better is a worker parked forever.

SIZE LIMITS

max_size, if set, refuses anything larger without scanning it and without transferring it. It is off by default, because a limit the client only believes clamd has would refuse work that would have succeeded.

Set it to match clamd's StreamMaxLength and MaxFileSize if you know them. Setting it larger than clamd's real limits is worse than leaving it unset: it reads like a safety belt while clamd silently declines to scan anything above its own ceiling.

Streaming something larger than clamd's StreamMaxLength is not a disaster - clamd replies INSTREAM size limit exceeded. ERROR and closes. It is an error, never a clean verdict.

error

The message from the last failed command, or undef if it succeeded.

error_code

The code for that failure: ERR_CONNECT, ERR_TIMEOUT, ERR_IO, ERR_TOOBIG, ERR_CONFIG, ERR_CLOSED, ERR_NOTREG or ERR_NOFDPASS. Compare against these rather than matching on message text.

have_fd_passing

Whether this platform can pass file descriptors over a socket. False on Windows, which has AF_UNIX but no SCM_RIGHTS.

FAILURE IS NEVER SUCCESS

No transport failure is reported as a verdict. Every command returns undef when it could not get an answer, and sets both "error" and a non-zero "error_code". A timeout, a refused connection, a peer that closed mid-reply and a reply that exceeded reply_max are all failures to answer, never answers.

AUTHOR

LNATION, <email at lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION.

This is free software, licensed under the Artistic License 2.0.