Use verified CLI preparations when web PHP cannot execute processes
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-13 13:57:01 +02:00
parent 258c8bd28b
commit 4988ca89dc
6 changed files with 117 additions and 9 deletions
+56 -6
View File
@@ -13,6 +13,48 @@ use Symfony\Component\Process\Process;
final class DemographicImport
{
/**
* CLI preparation is read-only input, never an import or approval.
* @return array{preview:string,records:string,receipt:array<string,mixed>}|null
*/
public function preparedSource(string $sha, ?int $baselineYear, bool $verifyRecords = true): ?array
{
$root = config('demography.prepared_directory');
if (! is_string($root) || $root === '') { return null; }
abort_unless(preg_match('/^[a-f0-9]{64}$/', $sha) === 1, 422);
$directory = $root.'/'.$sha;
if (! is_file($directory.'/preparation.json')) { return null; }
abort_unless(is_readable($directory.'/preparation.json'), 409, 'Recibo CLI indisponível.');
try { $receipt = json_decode(CheckedFile::read($directory.'/preparation.json'), true, 512, JSON_THROW_ON_ERROR); }
catch (\JsonException) { abort(409, 'Recibo CLI inválido.'); }
abort_unless(is_array($receipt), 409, 'Recibo CLI inválido.');
$preview = $directory.'/preview.json';
$records = $directory.'/records.ndjson';
abort_unless(($receipt['sha256'] ?? '') === $sha && array_key_exists('baseline_year', $receipt) && $receipt['baseline_year'] === $baselineYear
&& ($receipt['parser_sha256'] ?? '') === CheckedFile::sha256(base_path('tools/demography_workbook.py'))
&& is_file($preview) && CheckedFile::matches($preview, $receipt['preview_sha256'] ?? '') && is_file($records), 409, 'A preparação CLI mudou; conservar o original e preparar novamente fora do servidor web.');
try { $report = json_decode(CheckedFile::read($preview), true, 512, JSON_THROW_ON_ERROR); }
catch (\JsonException) { abort(409, 'Prévia CLI inválida.'); }
abort_unless(is_array($report), 409, 'Prévia CLI inválida.');
abort_unless(($report['sha256'] ?? '') === $sha && ($report['record_count'] ?? null) === ($receipt['record_count'] ?? null)
&& ($report['records_file_sha256'] ?? '') === ($receipt['records_sha256'] ?? '')
&& (! $verifyRecords || CheckedFile::matches($records, $receipt['records_sha256'] ?? '')), 409, 'Resultados da preparação CLI alterados.');
return ['preview' => $preview, 'records' => $records, 'receipt' => $receipt];
}
/** @param array<string,array<string,mixed>> $files */
public function preparedSourcesReady(array $files): bool
{
try {
foreach ($files as $name => $expected) {
$source = $this->preparedSource($expected['sha256'], $name === 'Dados demograficos.xlsx' ? 2017 : null, false);
if ($source === null || $source['receipt']['record_count'] !== $expected['records']) { return false; }
}
return $files !== [];
} catch (\Throwable) { return false; }
}
public function authorize(User $actor): void
{
$access = app(PortalProfileAccess::class);
@@ -43,13 +85,21 @@ final class DemographicImport
$scan = app(MalwareScanner::class)->scan($path, (int) filesize($path), $disk->path($directory));
abort_unless($scan['result'] === 'clean', 423, 'Análise antimalware pendente ou não limpa; original privado conservado.');
$output = $disk->path($directory.'/preview.json');
$command = [config('demography.python', 'python'), base_path('tools/demography_workbook.py'), $path, '--output', $output, '--records-output', $disk->path($directory.'/records.ndjson')];
if ($baselineYear !== null) {
array_push($command, '--baseline-year', (string) $baselineYear);
$prepared = $this->preparedSource($sha, $baselineYear);
if ($prepared !== null) {
abort_unless(copy($prepared['preview'], $output) && CheckedFile::matches($output, $prepared['receipt']['preview_sha256'])
&& copy($prepared['records'], $disk->path($directory.'/records.ndjson'))
&& CheckedFile::matches($disk->path($directory.'/records.ndjson'), $prepared['receipt']['records_sha256']), 409, 'Preparação CLI alterada durante a cópia.');
} else {
abort_unless(function_exists('proc_open'), 503, 'Este servidor web não executa o preparador. Prepare este original pelo importador CLI e confira o respectivo recibo antes de continuar.');
$command = [config('demography.python', 'python'), base_path('tools/demography_workbook.py'), $path, '--output', $output, '--records-output', $disk->path($directory.'/records.ndjson')];
if ($baselineYear !== null) {
array_push($command, '--baseline-year', (string) $baselineYear);
}
$process = new Process($command, base_path(), timeout: 120);
$process->run();
abort_unless($process->isSuccessful(), 422, 'Não foi possível reconhecer o Excel; confira a estrutura e o ano de referência.');
}
$process = new Process($command, base_path(), timeout: 120);
$process->run();
abort_unless($process->isSuccessful(), 422, 'Não foi possível reconhecer o Excel; confira a estrutura e o ano de referência.');
$report = json_decode(CheckedFile::read($output), true, 512, JSON_THROW_ON_ERROR);
abort_unless(hash_equals($sha, $report['sha256']) && CheckedFile::matches($path, $sha), 409);
$summary = array_diff_key($report, ['records' => true]);