From c0c74dfb6b8f24fc2878d6891b303f594963c64b Mon Sep 17 00:00:00 2001 From: Andrew Griess Date: Wed, 15 Jul 2026 11:12:35 -0500 Subject: [PATCH] Add catalog validation tool --- tools/validate-catalog.php | 201 +++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100755 tools/validate-catalog.php diff --git a/tools/validate-catalog.php b/tools/validate-catalog.php new file mode 100755 index 0000000..be288e9 --- /dev/null +++ b/tools/validate-catalog.php @@ -0,0 +1,201 @@ +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";