Files
projectsend/app/Modules/Files/Scanning/ScanVerdict.php
T
ignacionelson dc0937fda1 Add an Activity tab that shows a scan as it happens
A backfill runs for minutes or hours inside a queue worker, where none
of it is visible. The third tab polls every four seconds and says what
is happening: whether anything is running, how many uploads are held,
how deep the queue is, how many files were checked in the last hour,
and the last twenty verdicts with what each one was. When nothing is
running, that same list is the record of the last run, which is what
somebody opening the tab after the fact came for.

Two things the live screen found that the tests had not:

**A backfill read as "nothing is being scanned."** Re-scanning a file
that already went out unchecked deliberately leaves it available, so it
is never "pending" — and the screen counted only pending files. It
counts the scans queue too, and the two are shown separately, because
"an upload nobody can download yet" and "work the scanner has not
reached" are different facts.

**A file whose bytes are missing was recorded as "the scanner could not
be reached."** Wrong on screen, and worse than wrong in behaviour: that
is the one reason the hourly sweep re-queues, so every orphaned row
would have been rescanned every hour forever. It has its own reason
now, and goes through the same policy as a file the scanner could not
open.

Both tabs also gained the header shortcut to Quarantine, and Quarantine
one back to the settings, each shown only to somebody the destination
will actually let in.
2026-09-16 20:18:37 -03:00

58 lines
1.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
/**
* What a scanner answered about one file.
*
* Five outcomes rather than a boolean, because four of them are not
* "clean or not": a file the scanner refused to open, one too big for it,
* and a scanner that never answered are three different facts, and this
* installation's settings decide what each one means for the file. That
* decision lives in ScanPolicy, not here.
*/
final class ScanVerdict
{
private function __construct(
public readonly ScanOutcome $outcome,
/** The threat name, the reason a scan was refused, or null. */
public readonly ?string $detail = null,
/** Engine and definitions, as the scanner reported them. */
public readonly ?string $engine = null,
) {}
public static function clean(?string $engine = null): self
{
return new self(ScanOutcome::Clean, null, $engine);
}
public static function infected(string $threat, ?string $engine = null): self
{
return new self(ScanOutcome::Infected, $threat, $engine);
}
public static function tooLarge(?string $engine = null): self
{
return new self(ScanOutcome::TooLarge, null, $engine);
}
public static function encrypted(?string $engine = null): self
{
return new self(ScanOutcome::Encrypted, null, $engine);
}
/** The file could not be read, so nothing was scanned. */
public static function unreadable(string $reason): self
{
return new self(ScanOutcome::Unreadable, $reason);
}
/** The scanner could not be reached, or did not answer in time. */
public static function unavailable(string $reason): self
{
return new self(ScanOutcome::Unavailable, $reason);
}
}