malware_scan_attempts; if ($expectedAttempt !== null && $attempt !== $expectedAttempt) { return false; } $events = DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'document-scan')->where('entity_id', $document->id) ->where('action', 'scan_started')->whereRaw("after_data->>'attempt' = ?", [(string) $attempt])->get(['after_data']); if ($events->count() !== 1) { return false; } $lease = json_decode($events->first()->after_data, true, 512, JSON_THROW_ON_ERROR); return is_array($lease) && ($lease['run_id'] ?? null) === $run && ($lease['delivery_attempt'] ?? null) === $delivery && ($lease['attempt'] ?? null) === $attempt; } public function start(stdClass $document, string $run, int $delivery): int { $attempt = (int) $document->malware_scan_attempts + 1; DB::table('audit_events')->insert(['id' => (string) Str::uuid(), 'source_system' => self::SOURCE, 'entity_type' => 'document-scan', 'entity_id' => $document->id, 'actor_user_id' => null, 'action' => 'scan_started', 'correlation_id' => $run, 'data_classification' => 'restricted', 'after_data' => json_encode(['run_id' => $run, 'delivery_attempt' => $delivery, 'attempt' => $attempt, 'file_sha256' => trim($document->sha256), 'file_size_bytes' => $document->size_bytes ?? null], JSON_THROW_ON_ERROR)]); return $attempt; } /** @return array{sha256:string,size_bytes:int|null}|null */ public function descriptor(stdClass $document, string $run, int $delivery): ?array { if (! $this->owns($document, $run, $delivery)) { return null; } $event = DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'document-scan')->where('entity_id', $document->id) ->where('action', 'scan_started')->whereRaw("after_data->>'attempt' = ?", [(string) $document->malware_scan_attempts])->first(['after_data']); $data = $event ? json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR) : null; if (! is_array($data) || ! is_string($data['file_sha256'] ?? null) || preg_match('/^[a-f0-9]{64}$/D', $data['file_sha256']) !== 1 || ! array_key_exists('file_size_bytes', $data) || ($data['file_size_bytes'] !== null && ! is_int($data['file_size_bytes']))) { return null; } return ['sha256' => $data['file_sha256'], 'size_bytes' => $data['file_size_bytes']]; } public function completed(string $documentId, string $run): bool { return DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'document-scan')->where('entity_id', $documentId) ->where('action', 'scan_result_recorded')->where('correlation_id', $run)->whereRaw("after_data->>'retryable_failure' = 'false'")->exists(); } public function finish(stdClass $document, string $run, int $delivery, string $scanId, bool $retryableFailure): void { DB::table('audit_events')->insert(['id' => (string) Str::uuid(), 'source_system' => self::SOURCE, 'entity_type' => 'document-scan', 'entity_id' => $document->id, 'actor_user_id' => null, 'action' => 'scan_result_recorded', 'correlation_id' => $run, 'data_classification' => 'restricted', 'after_data' => json_encode(['run_id' => $run, 'delivery_attempt' => $delivery, 'attempt' => (int) $document->malware_scan_attempts, 'scan_id' => $scanId, 'retryable_failure' => $retryableFailure], JSON_THROW_ON_ERROR)]); } public function discarded(stdClass $document, string $run, int $delivery, int $expectedAttempt): void { DB::table('audit_events')->insert(['id' => (string) Str::uuid(), 'source_system' => self::SOURCE, 'entity_type' => 'document-scan', 'entity_id' => $document->id, 'actor_user_id' => null, 'action' => 'scan_result_discarded', 'correlation_id' => $run, 'data_classification' => 'restricted', 'after_data' => json_encode(['run_id' => $run, 'delivery_attempt' => $delivery, 'attempt' => $expectedAttempt, 'current_attempt' => (int) $document->malware_scan_attempts], JSON_THROW_ON_ERROR)]); } }