'Registry requires authentication this app cannot satisfy.']; } $token = self::fetchToken($challenge); if ($token === null) { return ['error' => 'Could not authenticate with the registry.']; } [$status, $headers] = self::httpRequest($manifestUrl, $accept, $token); } if ($status !== 200) { return ['error' => "Registry returned HTTP {$status}."]; } $remoteDigest = $headers['docker-content-digest'] ?? null; if ($remoteDigest === null) { return ['error' => 'Registry response did not include a digest.']; } return ['remoteDigest' => $remoteDigest, 'updateAvailable' => $remoteDigest !== $localDigest]; } /** * Splits "docker.io/library/nginx:alpine" (or shorthand forms like * "nginx:alpine" or "someuser/repo:tag") into [registryHost, repoPath, * tag] — same reference-parsing convention every registry client * (including podman/Docker themselves) uses: the first path segment is * a registry host only if it contains a "." or ":" or is "localhost"; * otherwise the whole reference is a Docker Hub repo, implicitly under * "library/" if it has no namespace of its own. docker.io's actual API * host is registry-1.docker.io, not docker.io itself — a Docker-Hub- * specific quirk, not something inferred from the general rule above. * * @return array{0:string,1:string,2:string} */ private static function parseReference(string $reference): array { $reference = explode('@', $reference, 2)[0]; // strip any @sha256:... suffix $tag = 'latest'; $lastSlash = strrpos($reference, '/'); $lastColon = strrpos($reference, ':'); if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) { $tag = substr($reference, $lastColon + 1); $reference = substr($reference, 0, $lastColon); } $parts = explode('/', $reference); $first = $parts[0]; $looksLikeHost = str_contains($first, '.') || str_contains($first, ':') || $first === 'localhost'; if ($looksLikeHost) { $registry = $first; $repo = implode('/', array_slice($parts, 1)); } else { $registry = 'docker.io'; $repo = str_contains($reference, '/') ? $reference : "library/{$reference}"; } if ($registry === 'docker.io') { $registry = 'registry-1.docker.io'; } return [$registry, $repo, $tag]; } /** @return array{realm:string,service:string,scope:string}|null */ private static function parseAuthChallenge(string $header): ?array { if (preg_match('/realm="([^"]+)"/', $header, $m) !== 1) { return null; } $service = preg_match('/service="([^"]+)"/', $header, $sm) === 1 ? $sm[1] : ''; $scope = preg_match('/scope="([^"]+)"/', $header, $om) === 1 ? $om[1] : ''; return ['realm' => $m[1], 'service' => $service, 'scope' => $scope]; } /** @param array{realm:string,service:string,scope:string} $challenge */ private static function fetchToken(array $challenge): ?string { $params = array_filter(['service' => $challenge['service'], 'scope' => $challenge['scope']]); $url = $challenge['realm'] . '?' . http_build_query($params); [$status, , $body] = self::httpRequest($url, 'application/json', null, true); if ($status !== 200 || $body === null) { return null; } $decoded = json_decode($body, true); // The spec allows either key; registries are inconsistent about // which one they actually send. return is_array($decoded) ? (string) ($decoded['token'] ?? $decoded['access_token'] ?? '') ?: null : null; } /** * @return array{0:int,1:array,2:?string} [status, lowercased response headers, body (only when $withBody)] */ private static function httpRequest(string $url, string $accept, ?string $token, bool $withBody = false): array { $ch = curl_init($url); $headers = ['Accept: ' . $accept]; if ($token !== null) { $headers[] = "Authorization: Bearer {$token}"; } curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_TIMEOUT => 15, CURLOPT_FOLLOWLOCATION => true, ]); $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); if ($raw === false) { return [0, [], null]; } $parsedHeaders = []; foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) { if (str_contains($line, ':')) { [$k, $v] = explode(':', $line, 2); $parsedHeaders[strtolower(trim($k))] = trim($v); } } $body = $withBody ? substr($raw, $headerSize) : null; return [$status, $parsedHeaders, $body]; } }