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";