|null */ public ?array $opportunityContext = null; /** @param array|null $opportunityContext */ public function __construct(public readonly string $documentId, ?array $opportunityContext = null) { $this->executionId = (string) Str::uuid(); $this->opportunityContext = $opportunityContext; } /** Queue UUID also supports payloads serialized before executionId existed. */ public function runIdentifier(): string { if ($this->executionId !== null) { return $this->executionId; } $queued = $this->job?->uuid(); return is_string($queued) && Str::isUuid($queued) ? $queued : ($this->executionId = (string) Str::uuid()); } public function uniqueId(): string { return $this->documentId; } /** @return list */ public function backoff(): array { return [15, 60]; } public function handle(MalwareScanner $scanner): void { $document = DB::transaction(function (): ?\stdClass { $row = DB::table('documents')->where('id', $this->documentId)->lockForUpdate()->first(); if (! $row || $row->status === 'deleted' || $row->deleted_at !== null) { return null; } if ($row->malware_scan_status === 'clean' && hash_equals(trim($row->sha256), (string) $row->malware_scanned_sha256)) { return null; } if ($row->malware_scan_status === 'infected') { return null; } $attempts = app(DocumentScanAttempts::class); if ($attempts->completed($row->id, $this->runIdentifier())) { return null; } if ($row->malware_scan_status === 'scanning' && $attempts->owns($row, $this->runIdentifier(), (int) $this->attempts())) { return null; } $attempt = $attempts->start($row, $this->runIdentifier(), (int) $this->attempts()); DB::table('documents')->where('id', $this->documentId)->update([ 'status' => 'quarantined', 'malware_scan_status' => 'scanning', 'malware_failure_reason' => null, 'malware_scan_attempts' => $attempt, 'updated_at' => now(), ]); $row->malware_scan_attempts = $attempt; return $row; }); if (! $document) { return; } $startedAt = now(); $disk = Storage::disk('local'); $root = $disk->path(''); $path = $disk->path($document->storage_key); $temporary = null; $operationFailed = false; try { if (($document->storage_encryption ?? null) === 'laravel') { $temporary = tempnam($root, 'scan-private-'); if ($temporary === false) { throw new \RuntimeException('Private scan workspace unavailable'); } if (! @chmod($temporary, 0600)) { throw new \RuntimeException('Private scan workspace permissions unavailable'); } $plain = Crypt::decryptString($disk->get($document->storage_key)); if (file_put_contents($temporary, $plain) !== strlen($plain)) { throw new \RuntimeException('Private scan workspace write incomplete'); } unset($plain); $path = $temporary; } $beforeHash = $this->fileHash($path); if ($beforeHash === null || ! hash_equals(trim($document->sha256), $beforeHash)) { $applied = $this->record($document, [ 'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'not_run', 'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null, ], $startedAt, 'inconclusive'); if ($applied) { $this->notify('scan_inconclusive'); } return; } $result = $scanner->scan($path, (int) ($document->size_bytes ?? filesize($path)), $root); $afterHash = $this->fileHash($path); if ($afterHash === null || ! hash_equals(trim($document->sha256), $afterHash)) { $result['result'] = 'inconclusive'; $result['reason_code'] = 'file_changed_during_scan'; $result['detail'] = null; } $result['file_sha256'] = $afterHash; $applied = $this->record($document, $result, $startedAt, $result['result']); if (in_array($applied, ['inconclusive', 'failed'], true)) { $this->notify($applied === 'failed' ? 'scan_worker_failed' : 'scan_inconclusive'); } } catch (Throwable $error) { $operationFailed = true; throw $error; } finally { if (is_string($temporary) && is_file($temporary)) { try { $removed = @unlink($temporary); } catch (Throwable) { $removed = false; } if (! $removed) { try { Log::warning('Private scan workspace cleanup requires verification.', ['reference' => $this->runIdentifier(), 'error_code' => 'scan_cleanup_unconfirmed']); } catch (Throwable) { } if (! $operationFailed) { $this->notify('scan_cleanup_unconfirmed'); } } } } } public function failed(Throwable $exception): void { $document = DB::table('documents')->where('id', $this->documentId)->first(); if (! $document || $document->status === 'deleted' || $document->deleted_at !== null) { return; } if ($document->malware_scan_status === 'pending') { // A failure before claiming the row cannot overwrite another scan result. $this->notify('scan_worker_failed'); return; } if ($document->malware_scan_status !== 'scanning' || ! app(DocumentScanAttempts::class)->owns($document, $this->runIdentifier(), (int) $this->attempts())) { return; } $descriptor = app(DocumentScanAttempts::class)->descriptor($document, $this->runIdentifier(), (int) $this->attempts()); if ($descriptor === null) { $this->notify('scan_worker_failed'); return; } $document->sha256 = $descriptor['sha256']; $document->size_bytes = $descriptor['size_bytes']; $applied = $this->record($document, [ 'result' => 'failed', 'reason_code' => 'scan_failed_after_retries', 'engine' => 'unknown', 'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => null, 'detail' => null, ], now(), 'failed'); if ($applied) { $this->notify('scan_worker_failed'); } } private function notify(string $errorCode): void { if ($this->opportunityContext !== null) { app(OpportunityTechnicalIncidents::class)->recordScan($this->opportunityContext, $this->runIdentifier(), $errorCode); } } private function fileHash(string $path): ?string { // Re-read the filesystem after the scanner; files may change meanwhile. clearstatcache(true, $path); $hash = is_file($path) ? hash_file('sha256', $path) : false; return is_string($hash) ? $hash : null; } /** @param array $result */ private function record(\stdClass $document, array $result, \DateTimeInterface $startedAt, string $state): ?string { return DB::transaction(function () use ($document, $result, $startedAt, $state): ?string { $locked = DB::table('documents')->where('id', $this->documentId)->lockForUpdate()->first(); if (! $locked || $locked->status === 'deleted' || $locked->deleted_at !== null) { return null; } if ($locked->malware_scan_status !== 'scanning' || ! app(DocumentScanAttempts::class)->owns($locked, $this->runIdentifier(), (int) $this->attempts(), (int) $document->malware_scan_attempts)) { app(DocumentScanAttempts::class)->discarded($locked, $this->runIdentifier(), (int) $this->attempts(), (int) $document->malware_scan_attempts); return null; } $sameBytes = hash_equals(trim($document->sha256), trim((string) $locked->sha256)); $finalState = $sameBytes ? $state : 'inconclusive'; $reason = $sameBytes ? $result['reason_code'] : 'document_record_changed_during_scan'; $release = $finalState === 'clean' && hash_equals(trim($locked->sha256), trim((string) ($result['file_sha256'] ?? ''))); $finishedAt = now(); $scanId = (string) Str::uuid(); DB::table('documents')->where('id', $this->documentId)->update([ 'status' => $release ? 'active' : 'quarantined', 'malware_scan_status' => $finalState, 'malware_scanned_sha256' => $sameBytes && hash_equals(trim($locked->sha256), trim((string) ($result['file_sha256'] ?? ''))) ? trim($locked->sha256) : null, 'malware_engine' => $result['engine'] ?? 'ClamAV', 'malware_engine_version' => $result['engine_version'] ?? 'unknown', 'malware_signatures_at' => $result['signatures_at'] ?? null, 'malware_scanned_at' => $finishedAt, 'malware_failure_reason' => $reason, 'updated_at' => $finishedAt, ]); DB::table('document_malware_scans')->insert([ 'id' => $scanId, 'document_id' => $this->documentId, 'file_sha256' => trim($document->sha256), 'result' => $finalState, 'reason_code' => $reason, 'engine' => $result['engine'] ?? 'ClamAV', 'engine_version' => $result['engine_version'] ?? 'unknown', 'signatures_at' => $result['signatures_at'] ?? null, 'started_at' => $startedAt, 'finished_at' => $finishedAt, 'duration_ms' => $result['duration_ms'] ?? null, 'file_size_bytes' => $document->size_bytes ?? null, 'created_at' => $finishedAt, 'updated_at' => $finishedAt, ]); app(DocumentScanAttempts::class)->finish($document, $this->runIdentifier(), (int) $this->attempts(), $scanId, $state === 'failed'); return $finalState; }); } }