143 lines
7.2 KiB
PHP
143 lines
7.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use RuntimeException;
|
|
use SimpleXMLElement;
|
|
use ZipArchive;
|
|
|
|
/** Inspects an authorised local workbook without importing or transforming data. */
|
|
final class CartographyWorkbookAudit
|
|
{
|
|
/** @param array<string, mixed> $manifest
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function inspect(string $path, array $manifest): array
|
|
{
|
|
if (! is_file($path) || filesize($path) > 20_000_000) {
|
|
throw new RuntimeException('Workbook missing or larger than 20 MB.');
|
|
}
|
|
$checksum = hash_file('sha256', $path);
|
|
if ($checksum === false) {
|
|
throw new RuntimeException('Unable to read workbook checksum.');
|
|
}
|
|
if (! isset($manifest['sha256']) || ! hash_equals(strtolower((string) $manifest['sha256']), $checksum)) {
|
|
throw new RuntimeException('Manifest checksum does not match the workbook.');
|
|
}
|
|
foreach (['inspection_authorization'] as $key) {
|
|
if (! is_string($manifest[$key] ?? null) || trim($manifest[$key]) === '') {
|
|
throw new RuntimeException('Missing manifest field: '.$key);
|
|
}
|
|
}
|
|
if (isset($manifest['classification']) && ! in_array($manifest['classification'], ['public', 'internal', 'confidential', 'restricted'], true)) {
|
|
throw new RuntimeException('Invalid classification.');
|
|
}
|
|
$zip = new ZipArchive;
|
|
if ($zip->open($path) !== true) {
|
|
throw new RuntimeException('Invalid XLSX container.');
|
|
}
|
|
try {
|
|
$bytes = 0;
|
|
if ($zip->numFiles > 1000) {
|
|
throw new RuntimeException('Too many archive entries.');
|
|
}
|
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
|
$entry = $zip->statIndex($i);
|
|
if ($entry === false) {
|
|
throw new RuntimeException('Unreadable archive entry.');
|
|
}
|
|
$bytes += $entry['size'];
|
|
if ($bytes > 50_000_000 || str_contains($entry['name'], '../')) {
|
|
throw new RuntimeException('Unsafe or oversized archive.');
|
|
}
|
|
}
|
|
$strings = [];
|
|
if ($zip->locateName('xl/sharedStrings.xml') !== false) {
|
|
foreach ($this->xml($zip, 'xl/sharedStrings.xml')->xpath('//*[local-name()="si"]') ?: [] as $item) {
|
|
$strings[] = implode('', array_map(fn ($t) => (string) $t, $item->xpath('.//*[local-name()="t"]') ?: []));
|
|
}
|
|
}
|
|
$relationships = [];
|
|
foreach ($this->xml($zip, 'xl/_rels/workbook.xml.rels')->children() as $rel) {
|
|
$target = (string) $rel['Target'];
|
|
if ((string) $rel['TargetMode'] !== 'External' && ! str_contains($target, '..')) {
|
|
$relationships[(string) $rel['Id']] = str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/'.$target;
|
|
}
|
|
}
|
|
$sheets = [];
|
|
$rowCount = 0;
|
|
foreach ($this->xml($zip, 'xl/workbook.xml')->xpath('//*[local-name()="sheet"]') ?: [] as $sheet) {
|
|
$id = (string) $sheet->attributes('http://schemas.openxmlformats.org/officeDocument/2006/relationships')['id'];
|
|
if (! isset($relationships[$id])) {
|
|
throw new RuntimeException('Unresolved worksheet relationship.');
|
|
}
|
|
$rows = [];
|
|
$seen = [];
|
|
foreach ($this->xml($zip, $relationships[$id])->xpath('//*[local-name()="sheetData"]/*[local-name()="row"]') ?: [] as $row) {
|
|
if (++$rowCount > 50000) {
|
|
throw new RuntimeException('Audit limit of 50000 rows exceeded.');
|
|
}
|
|
$values = [];
|
|
$issues = [];
|
|
foreach ($row->children() as $cell) {
|
|
$address = (string) $cell['r'];
|
|
$value = (string) $cell->v;
|
|
if ((string) $cell['t'] === 's') {
|
|
$value = $strings[(int) $value] ?? '';
|
|
} elseif ((string) $cell['t'] === 'inlineStr') {
|
|
$value = implode('', array_map(fn ($t) => (string) $t, $cell->xpath('.//*[local-name()="t"]') ?: []));
|
|
}
|
|
$values[preg_replace('/[0-9]+$/', '', $address)] = $value;
|
|
if ($value === '') {
|
|
$issues[] = ['field' => $address, 'code' => 'empty_cell'];
|
|
}
|
|
if (isset($cell->f)) {
|
|
$issues[] = ['field' => $address, 'code' => 'formula_not_evaluated'];
|
|
}
|
|
if ((string) $cell['t'] === 'e') {
|
|
$issues[] = ['field' => $address, 'code' => 'spreadsheet_error'];
|
|
}
|
|
}
|
|
$hash = hash('sha256', json_encode($values, JSON_THROW_ON_ERROR));
|
|
$duplicate = isset($seen[$hash]);
|
|
$seen[$hash] = true;
|
|
$issues[] = ['field' => '*', 'code' => 'semantic_mapping_and_institutional_validation_pending'];
|
|
if (empty($manifest['original_crs'])) {
|
|
$issues[] = ['field' => '*', 'code' => 'original_crs_unknown'];
|
|
}
|
|
$rows[] = ['row' => (int) $row['r'], 'status' => $duplicate ? 'duplicate' : 'pending', 'populated_columns' => array_keys($values), 'issues' => $issues];
|
|
}
|
|
$sheets[] = ['name' => (string) $sheet['name'], 'state' => (string) $sheet['state'] ?: 'visible', 'rows' => $rows];
|
|
}
|
|
|
|
return ['sha256' => $checksum, 'version' => $manifest['version'] ?? null, 'classification' => $manifest['classification'] ?? null,
|
|
'pending_metadata' => array_values(array_filter(['owner', 'version', 'received_at', 'classification', 'original_crs'], fn ($key) => empty($manifest[$key]))),
|
|
'original_crs' => $manifest['original_crs'] ?? null, 'publication_allowed' => false, 'transformations' => [],
|
|
'status' => 'quarantined', 'sheets' => $sheets, 'rows_inspected' => $rowCount,
|
|
'limitations' => ['No semantic header mapping, omitted-cell inference, coordinate validation or external reference resolution.', 'No source values, coordinates or formulas copied to the report.']];
|
|
} finally {
|
|
$zip->close();
|
|
}
|
|
}
|
|
|
|
private function xml(ZipArchive $zip, string $name): SimpleXMLElement
|
|
{
|
|
$content = $zip->getFromName($name);
|
|
if ($content === false || preg_match('/<!DOCTYPE|<!ENTITY/i', $content)) {
|
|
throw new RuntimeException('Missing or unsafe workbook XML.');
|
|
}
|
|
$previous = libxml_use_internal_errors(true);
|
|
try {
|
|
$xml = simplexml_load_string($content, SimpleXMLElement::class, LIBXML_NONET);
|
|
if ($xml === false) {
|
|
throw new RuntimeException('Malformed workbook XML.');
|
|
}
|
|
|
|
return $xml;
|
|
} finally {
|
|
libxml_clear_errors();
|
|
libxml_use_internal_errors($previous);
|
|
}
|
|
}
|
|
}
|