Files
RoundCount-Catalog/tools/validate-catalog.php
2026-07-15 11:12:35 -05:00

202 lines
5.6 KiB
PHP
Executable File

<?php
declare(strict_types=1);
$root = dirname(__DIR__);
$catalogDir = $root . '/catalog';
$allowedStatuses = [
'draft',
'unverified',
'verified',
'discontinued',
'duplicate',
];
$allowedImageExtensions = ['webp', 'jpg', 'jpeg', 'png'];
$errors = [];
$checked = 0;
if (!is_dir($catalogDir)) {
fwrite(STDERR, "Catalog directory not found: {$catalogDir}\n");
exit(1);
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$catalogDir,
FilesystemIterator::SKIP_DOTS
)
);
foreach ($iterator as $file) {
if (!$file->isFile() || strtolower($file->getExtension()) !== 'json') {
continue;
}
$checked++;
$path = $file->getPathname();
$relativePath = substr($path, strlen($root) + 1);
$raw = file_get_contents($path);
if ($raw === false) {
$errors[] = "{$relativePath}: unable to read file";
continue;
}
try {
$record = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$errors[] = "{$relativePath}: invalid JSON: {$exception->getMessage()}";
continue;
}
if (!is_array($record)) {
$errors[] = "{$relativePath}: root value must be an object";
continue;
}
$required = [
'catalog_version',
'record_uuid',
'manufacturer',
'product_name',
'caliber',
'status',
'last_updated',
];
foreach ($required as $field) {
if (!array_key_exists($field, $record)) {
$errors[] = "{$relativePath}: missing required field '{$field}'";
}
}
if (($record['catalog_version'] ?? null) !== 1) {
$errors[] = "{$relativePath}: catalog_version must be 1";
}
if (
isset($record['record_uuid']) &&
!preg_match(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
(string) $record['record_uuid']
)
) {
$errors[] = "{$relativePath}: record_uuid is not a valid UUID";
}
foreach (['manufacturer', 'product_name', 'caliber'] as $field) {
if (
isset($record[$field]) &&
(!is_string($record[$field]) || trim($record[$field]) === '')
) {
$errors[] = "{$relativePath}: {$field} must be a non-empty string";
}
}
if (
isset($record['upc']) &&
$record['upc'] !== null &&
!preg_match('/^[0-9]{8,14}$/', (string) $record['upc'])
) {
$errors[] = "{$relativePath}: upc must contain 8 to 14 digits";
}
if (
isset($record['status']) &&
!in_array($record['status'], $allowedStatuses, true)
) {
$errors[] = "{$relativePath}: invalid status '{$record['status']}'";
}
if (
isset($record['last_updated']) &&
!preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $record['last_updated'])
) {
$errors[] = "{$relativePath}: last_updated must use YYYY-MM-DD";
}
if (
isset($record['sources']) &&
!is_array($record['sources'])
) {
$errors[] = "{$relativePath}: sources must be an array";
}
if (
($record['status'] ?? null) === 'verified' &&
empty($record['sources'])
) {
$errors[] = "{$relativePath}: verified records must include at least one source";
}
if (
isset($record['image']) &&
$record['image'] !== null
) {
if (!is_array($record['image'])) {
$errors[] = "{$relativePath}: image must be an object or null";
} else {
$imagePath = $record['image']['path'] ?? null;
if (!is_string($imagePath) || $imagePath === '') {
$errors[] = "{$relativePath}: image.path must be a non-empty string";
} else {
if (!str_starts_with($imagePath, 'images/')) {
$errors[] = "{$relativePath}: image.path must start with images/";
}
if (str_contains($imagePath, '..')) {
$errors[] = "{$relativePath}: image.path may not contain ..";
}
$extension = strtolower(pathinfo($imagePath, PATHINFO_EXTENSION));
if (!in_array($extension, $allowedImageExtensions, true)) {
$errors[] = "{$relativePath}: unsupported image extension '{$extension}'";
}
$fullImagePath = $root . '/' . $imagePath;
if (!is_file($fullImagePath)) {
$errors[] = "{$relativePath}: referenced image does not exist: {$imagePath}";
}
$checksum = $record['image']['sha256'] ?? null;
if (
$checksum !== null &&
!preg_match('/^[a-f0-9]{64}$/i', (string) $checksum)
) {
$errors[] = "{$relativePath}: image.sha256 must be 64 hexadecimal characters";
}
if (
$checksum !== null &&
is_file($fullImagePath)
) {
$actualChecksum = hash_file('sha256', $fullImagePath);
if (!hash_equals(strtolower($checksum), strtolower($actualChecksum))) {
$errors[] = "{$relativePath}: image SHA-256 does not match";
}
}
}
}
}
}
if ($errors !== []) {
fwrite(STDERR, "Catalog validation failed:\n\n");
foreach ($errors as $error) {
fwrite(STDERR, " - {$error}\n");
}
fwrite(STDERR, "\nChecked {$checked} product file(s).\n");
exit(1);
}
echo "Catalog validation passed. Checked {$checked} product file(s).\n";