budget !== null) { throw new RuntimeException('An outbound sync budget is already active.'); } $this->budget = $budget; } public function endBudget(SyncBudget $budget): void { if ($this->budget === $budget) { $this->budget = null; } } public function consumeRepositories(int $count): void { $this->budget?->consumeRepositories($count); } public function consumeReleases(int $count): void { $this->budget?->consumeReleases($count); } /** Defense-in-depth for adapters which do not account per page. */ public function ensureRepositoriesCounted(int $total): void { if ($this->budget !== null && $total > $this->budget->repositoriesCounted()) { $this->budget->consumeRepositories($total - $this->budget->repositoriesCounted()); } } /** Defense-in-depth for adapters which do not account per page. */ public function ensureReleasesCounted(int $total): void { if ($this->budget !== null && $total > $this->budget->releasesCounted()) { $this->budget->consumeReleases($total - $this->budget->releasesCounted()); } } /** @param list $headers @return array|null */ public function getJson(string $url, array $headers = [], ?string $sensitiveOrigin = null, bool $allowNotFound = false): ?array { $response = $this->request($url, $headers, $sensitiveOrigin, $allowNotFound); if ($response === null) { return null; } $decoded = json_decode($response['body'], true, 512, JSON_THROW_ON_ERROR); if (!is_array($decoded)) { throw new RuntimeException('Remote source returned invalid JSON.'); } return $decoded; } /** @param list $headers */ public function getText(string $url, array $headers = [], ?string $sensitiveOrigin = null, bool $allowNotFound = false): ?string { $response = $this->request($url, $headers, $sensitiveOrigin, $allowNotFound); return $response['body'] ?? null; } /** @return array{sha256:string,artifactSize:int} */ public function downloadAndHash(string $url, string $apiOrigin, ?string $authorization, string $expectedSha256 = ''): array { $current = $url; for ($redirects = 0; $redirects <= 5; $redirects++) { $headers = ['Accept: application/octet-stream']; if ($authorization !== null && $this->origin($current) === $apiOrigin) { $headers[] = 'Authorization: ' . $authorization; } $response = $this->performWithRetries($current, $headers, true); if (in_array($response['status'], [301, 302, 303, 307, 308], true)) { $location = $response['headers']['location'] ?? ''; if ($location === '') { throw new RuntimeException('Artifact redirect has no Location header.'); } $current = $this->resolveRedirect($current, $location); continue; } if ($response['status'] < 200 || $response['status'] >= 300) { throw new RuntimeException('Artifact host returned HTTP ' . $response['status'] . '.'); } if ($response['tooLarge']) { throw new RuntimeException('Artifact exceeds configured size limit.'); } if ($expectedSha256 !== '' && preg_match('/^[a-f0-9]{64}$/i', $expectedSha256) && !hash_equals(strtolower($expectedSha256), $response['sha256'])) { throw new RuntimeException('Downloaded artifact differs from advertised SHA-256.'); } return ['sha256' => $response['sha256'], 'artifactSize' => $response['size']]; } throw new RuntimeException('Artifact has too many redirects.'); } /** @param list $headers @return array{status:int,headers:array,body:string}|null */ private function request(string $url, array $headers, ?string $sensitiveOrigin, bool $allowNotFound): ?array { $current = $url; for ($redirects = 0; $redirects <= 5; $redirects++) { $filtered = $this->filterSensitiveHeaders($headers, $current, $sensitiveOrigin); $response = $this->performWithRetries($current, $filtered, false); if ($allowNotFound && $response['status'] === 404) { return null; } if (in_array($response['status'], [301, 302, 303, 307, 308], true)) { $location = $response['headers']['location'] ?? ''; if ($location === '') { throw new RuntimeException('Redirect has no Location header.'); } $current = $this->resolveRedirect($current, $location); continue; } if ($response['status'] < 200 || $response['status'] >= 300) { throw new RuntimeException('Remote source returned HTTP ' . $response['status'] . '.'); } if ($response['tooLarge']) { throw new RuntimeException('Metadata response exceeds configured size limit.'); } return ['status' => $response['status'], 'headers' => $response['headers'], 'body' => $response['body']]; } throw new RuntimeException('Remote source has too many redirects.'); } /** @param list $headers @return array{status:int,headers:array,body:string,sha256:string,size:int,tooLarge:bool} */ private function performWithRetries(string $url, array $headers, bool $artifact): array { $last = null; for ($attempt = 1; $attempt <= 3; $attempt++) { $last = $this->perform($url, $headers, $artifact); if ($last['status'] !== 429 && $last['status'] < 500) { return $last; } if ($attempt < 3) { usleep($attempt * 350_000); } } return $last; } /** @param list $headers @return array{status:int,headers:array,body:string,sha256:string,size:int,tooLarge:bool} */ private function perform(string $url, array $headers, bool $artifact): array { $budget = $this->budget; $budget?->consumeRequest(); $target = $this->guard->resolve($url, $artifact ? 'Artifact URL' : 'Source URL'); $curl = curl_init($url); if (!$curl instanceof CurlHandle) { throw new RuntimeException('Could not initialize cURL.'); } $responseHeaders = []; $body = ''; $size = 0; $tooLarge = false; $budgetExceeded = false; $hash = hash_init('sha256'); $limit = $artifact ? $this->config->network['maxArtifactBytes'] : $this->config->network['maxMetadataBytes']; $timeout = $budget === null ? $this->config->network['timeout'] : min($this->config->network['timeout'], $budget->remainingSeconds()); curl_setopt_array($curl, [ CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => min(10, $timeout), CURLOPT_TIMEOUT => $timeout, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => array_merge(['User-Agent: ' . $this->config->network['userAgent']], $headers), CURLOPT_RESOLVE => [$target['resolve']], CURLOPT_HEADERFUNCTION => static function (CurlHandle $handle, string $line) use (&$responseHeaders): int { $trimmed = trim($line); if (str_starts_with($trimmed, 'HTTP/')) { $responseHeaders = []; } elseif (str_contains($trimmed, ':')) { [$name, $value] = explode(':', $trimmed, 2); $responseHeaders[strtolower(trim($name))] = trim($value); } return strlen($line); }, CURLOPT_WRITEFUNCTION => static function (CurlHandle $handle, string $chunk) use (&$body, &$size, &$tooLarge, &$budgetExceeded, $hash, $limit, $artifact, $budget): int { $chunkSize = strlen($chunk); $size += $chunkSize; if ($budget !== null && !$budget->tryConsumeBytes($chunkSize)) { $budgetExceeded = true; return 0; } if ($size > $limit) { $tooLarge = true; return 0; } if ($artifact) { hash_update($hash, $chunk); } else { $body .= $chunk; } return strlen($chunk); }, ]); $ok = curl_exec($curl); $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $error = curl_error($curl); curl_close($curl); if ($budgetExceeded) { $budget?->assertWithinLimits(); } $budget?->checkpoint(); if ($ok === false && !$tooLarge) { throw new RuntimeException('Outbound request failed: ' . $error); } return [ 'status' => $status, 'headers' => $responseHeaders, 'body' => $body, 'sha256' => hash_final($hash), 'size' => $size, 'tooLarge' => $tooLarge, ]; } /** @param list $headers @return list */ private function filterSensitiveHeaders(array $headers, string $url, ?string $sensitiveOrigin): array { if ($sensitiveOrigin !== null && $this->origin($url) === $sensitiveOrigin) { return $headers; } return array_values(array_filter($headers, static fn (string $header): bool => !str_starts_with(strtolower($header), 'authorization:'))); } public function origin(string $url): string { $parts = parse_url($url); $port = (int) ($parts['port'] ?? 443); return strtolower((string) ($parts['scheme'] ?? '')) . '://' . strtolower((string) ($parts['host'] ?? '')) . ($port === 443 ? '' : ':' . $port); } private function resolveRedirect(string $base, string $location): string { if (preg_match('#^https://#i', $location)) { return $location; } $parts = parse_url($base); $origin = $this->origin($base); if (str_starts_with($location, '/')) { return $origin . $location; } $directory = rtrim(dirname((string) ($parts['path'] ?? '/')), '/\\'); return $origin . ($directory === '' ? '' : $directory) . '/' . $location; } }