diff --git a/test/upstreamGapAnalyzer.test.js b/test/upstreamGapAnalyzer.test.js new file mode 100644 index 0000000..fa2e8d4 --- /dev/null +++ b/test/upstreamGapAnalyzer.test.js @@ -0,0 +1,136 @@ +const assert = require("assert"); +const { + analyzeUpstreamGaps, +} = require("../util/upstreamGapAnalyzer"); + +suite("upstreamGapAnalyzer", () => { + function createState(entries = {}) { + return { + groupedUpstreams: new Map(Object.entries(entries)), + }; + } + + test("classifies uncovered dependencies as reachable when a matching proxy exists", async () => { + const dependencies = [ + { + name: "accepts", + version: "1.3.8", + format: "npm", + ecosystem: "npm", + cloudsmithStatus: "NOT_FOUND", + }, + ]; + + const enriched = await analyzeUpstreamGaps(dependencies, "workspace-a", ["production"], { + upstreamChecker: { + async getRepositoryUpstreamState() { + return createState({ + npm: [ + { name: "npm", is_active: true }, + ], + }); + }, + }, + }); + + assert.strictEqual(enriched[0].upstreamStatus, "reachable"); + assert.strictEqual(enriched[0].upstreamDetail, "npm proxy on production"); + }); + + test("classifies supported formats with no proxy as no_proxy", async () => { + const dependencies = [ + { + name: "requests", + version: "2.31.0", + format: "python", + ecosystem: "python", + cloudsmithStatus: "NOT_FOUND", + }, + ]; + + const enriched = await analyzeUpstreamGaps(dependencies, "workspace-a", ["production"], { + upstreamChecker: { + async getRepositoryUpstreamState() { + return createState(); + }, + }, + }); + + assert.strictEqual(enriched[0].upstreamStatus, "no_proxy"); + assert.strictEqual(enriched[0].upstreamDetail, "No upstream proxy configured for python"); + }); + + test("classifies unsupported formats as unreachable", async () => { + const dependencies = [ + { + name: "custom-lib", + version: "1.0.0", + format: "custom", + ecosystem: "custom", + cloudsmithStatus: "NOT_FOUND", + }, + ]; + + const enriched = await analyzeUpstreamGaps(dependencies, "workspace-a", ["production"], { + upstreamChecker: { + async getRepositoryUpstreamState() { + return createState(); + }, + }, + }); + + assert.strictEqual(enriched[0].upstreamStatus, "unreachable"); + assert.strictEqual(enriched[0].upstreamDetail, "Not available through Cloudsmith"); + }); + + test("limits upstream repository lookups to five concurrent requests and emits one final patch", async () => { + const dependencies = [ + { + name: "accepts", + version: "1.3.8", + format: "npm", + ecosystem: "npm", + cloudsmithStatus: "NOT_FOUND", + }, + ]; + const repositories = Array.from({ length: 12 }, (_, index) => `repo-${index + 1}`); + const progressEvents = []; + let inFlight = 0; + let maxInFlight = 0; + + const enriched = await analyzeUpstreamGaps(dependencies, "workspace-a", repositories, { + onProgress: (patchMap, meta) => { + progressEvents.push({ + size: patchMap.size, + completed: meta.completed, + total: meta.total, + }); + }, + upstreamChecker: { + async getRepositoryUpstreamState(_workspace, repo) { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + + if (repo === "repo-9") { + return createState({ + npm: [ + { name: "npm", is_active: true }, + ], + }); + } + + return createState(); + }, + }, + }); + + assert.ok(maxInFlight <= 5); + assert.strictEqual(progressEvents.filter((event) => event.size > 0).length, 1); + assert.strictEqual(progressEvents[progressEvents.length - 1].completed, repositories.length); + assert.strictEqual(progressEvents[progressEvents.length - 1].size, 1); + assert.strictEqual(enriched[0].upstreamStatus, "reachable"); + assert.strictEqual(enriched[0].upstreamDetail, "npm proxy on repo-9"); + }); +}); diff --git a/test/upstreamPullService.test.js b/test/upstreamPullService.test.js new file mode 100644 index 0000000..bad0b01 --- /dev/null +++ b/test/upstreamPullService.test.js @@ -0,0 +1,312 @@ +const assert = require("assert"); +const { + buildRegistryTriggerPlan, +} = require("../util/registryEndpoints"); +const { + UpstreamPullService, +} = require("../util/upstreamPullService"); + +function createResponse(status, body, headers = {}) { + return { + status, + headers: { + get(name) { + const lowerName = String(name || "").toLowerCase(); + return headers[lowerName] || headers[name] || null; + }, + }, + async text() { + return body; + }, + }; +} + +suite("UpstreamPullService", () => { + test("builds canonical registry trigger URLs for supported formats", () => { + const mavenPlan = buildRegistryTriggerPlan("workspace", "repo", { + name: "com.example:demo-app", + version: "1.2.3", + format: "maven", + }); + assert.strictEqual( + mavenPlan.request.url, + "https://dl.cloudsmith.io/basic/workspace/repo/maven/com/example/demo-app/1.2.3/demo-app-1.2.3.pom" + ); + + const npmPlan = buildRegistryTriggerPlan("workspace", "repo", { + name: "@scope/widget", + version: "4.5.6", + format: "npm", + }); + assert.strictEqual( + npmPlan.request.url, + "https://npm.cloudsmith.io/workspace/repo/%40scope/widget/-/widget-4.5.6.tgz" + ); + + const goPlan = buildRegistryTriggerPlan("workspace", "repo", { + name: "github.com/MyOrg/MyModule", + version: "v1.0.0", + format: "go", + }); + assert.strictEqual( + goPlan.request.url, + "https://golang.cloudsmith.io/workspace/repo/github.com/!my!org/!my!module/@v/v1.0.0.info" + ); + + const cargoPlan = buildRegistryTriggerPlan("workspace", "repo", { + name: "serde", + version: "1.0.0", + format: "cargo", + }); + assert.strictEqual( + cargoPlan.request.url, + "https://cargo.cloudsmith.io/workspace/repo/se/rd/serde" + ); + }); + + test("prepare builds a mixed-ecosystem confirmation with skipped formats", async () => { + const warnings = []; + const service = new UpstreamPullService({}, { + fetchRepositories: async () => [{ slug: "repo", name: "Repo" }], + upstreamChecker: { + async getRepositoryUpstreamState() { + return { + groupedUpstreams: new Map([ + ["maven", [{ name: "Maven Central", is_active: true }]], + ]), + }; + }, + }, + showQuickPick: async (items) => items[0], + showWarningMessage: async (message, _options, action) => { + warnings.push(message); + return action; + }, + showErrorMessage: async () => {}, + showInformationMessage: async () => {}, + }); + + const prepared = await service.prepare({ + workspace: "workspace", + repositoryHint: "repo", + dependencies: [ + { + name: "com.example:demo-app", + version: "1.2.3", + format: "maven", + cloudsmithStatus: "NOT_FOUND", + }, + { + name: "requests", + version: "2.31.0", + format: "python", + cloudsmithStatus: "NOT_FOUND", + }, + ], + }); + + assert.ok(prepared); + assert.strictEqual(prepared.plan.pullableDependencies.length, 1); + assert.strictEqual(prepared.plan.skippedDependencies.length, 1); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /Pull 1 of 2 dependencies through repo\?/); + assert.match(warnings[0], /1 Maven will be pulled\./); + assert.match( + warnings[0], + /1 Python will be skipped \(no matching upstream is configured on this repository\)\./ + ); + }); + + test("pulls Python dependencies via same-host redirects using manual auth-preserving requests", async () => { + const calls = []; + const initialIndexUrl = "https://dl.cloudsmith.io/basic/workspace/repo/python/simple/requests/"; + const redirectedIndexUrl = "https://dl.cloudsmith.io/basic/workspace/repo/python/simple/requests/index.html"; + const artifactUrl = "https://dl.cloudsmith.io/basic/workspace/repo/python/packages/requests-2.31.0-py3-none-any.whl"; + const authorizationHeader = `Basic ${Buffer.from("token:api-key").toString("base64")}`; + const service = new UpstreamPullService({}, { + credentialManager: { + async getApiKey() { + return "api-key"; + }, + }, + fetchImpl: async (url, options) => { + calls.push({ url, options }); + if (url === initialIndexUrl) { + return createResponse(302, "", { + location: redirectedIndexUrl, + }); + } + if (url === redirectedIndexUrl) { + return createResponse(200, 'requests'); + } + if (url === artifactUrl) { + return createResponse(200, ""); + } + throw new Error(`Unexpected URL: ${url}`); + }, + showErrorMessage: async () => {}, + showInformationMessage: async () => {}, + showWarningMessage: async () => {}, + }); + + const result = await service.execute({ + workspace: "workspace", + repository: { slug: "repo" }, + plan: { + pullableDependencies: [{ + name: "requests", + version: "2.31.0", + format: "python", + cloudsmithStatus: "NOT_FOUND", + }], + skippedDependencies: [], + }, + }); + + assert.strictEqual(result.canceled, false); + assert.strictEqual(result.pullResult.cached, 1); + assert.strictEqual(calls.length, 3); + assert.deepStrictEqual( + calls.map((call) => call.url), + [initialIndexUrl, redirectedIndexUrl, artifactUrl] + ); + assert.strictEqual(calls.every((call) => call.options.redirect === "manual"), true); + assert.strictEqual( + calls.every((call) => call.options.headers.Authorization === authorizationHeader), + true + ); + }); + + test("rejects redirects to untrusted hosts before forwarding credentials", async () => { + const calls = []; + const service = new UpstreamPullService({}, { + credentialManager: { + async getApiKey() { + return "api-key"; + }, + }, + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return createResponse(302, "", { + location: "https://example.com/requests-2.31.0.whl", + }); + }, + showErrorMessage: async () => {}, + showInformationMessage: async () => {}, + showWarningMessage: async () => {}, + }); + + const result = await service.execute({ + workspace: "workspace", + repository: { slug: "repo" }, + plan: { + pullableDependencies: [{ + name: "requests", + version: "2.31.0", + format: "python", + cloudsmithStatus: "NOT_FOUND", + }], + skippedDependencies: [], + }, + }); + + assert.strictEqual(result.canceled, false); + assert.strictEqual(result.pullResult.cached, 0); + assert.strictEqual(result.pullResult.errors, 1); + assert.strictEqual(calls.length, 1); + assert.match(result.pullResult.details[0].errorMessage, /redirect target was rejected/i); + }); + + test("stops after three authentication failures before expanding concurrency", async () => { + const calls = []; + const errors = []; + const service = new UpstreamPullService({}, { + credentialManager: { + async getApiKey() { + return "api-key"; + }, + }, + fetchImpl: async (url) => { + calls.push(url); + return createResponse(401, ""); + }, + showErrorMessage: async (message) => { + errors.push(message); + }, + showInformationMessage: async () => {}, + showWarningMessage: async () => {}, + }); + + const dependencies = Array.from({ length: 5 }, (_, index) => ({ + name: `package-${index}`, + version: "1.0.0", + format: "npm", + cloudsmithStatus: "NOT_FOUND", + })); + + const result = await service.execute({ + workspace: "workspace", + repository: { slug: "repo" }, + plan: { + pullableDependencies: dependencies, + skippedDependencies: [], + }, + }); + + assert.strictEqual(calls.length, 3); + assert.strictEqual(result.pullResult.errors, 5); + assert.strictEqual(result.pullResult.authFailed, 5); + assert.deepStrictEqual(errors, [ + "Authentication failed. Check your API key in Cloudsmith settings.", + ]); + }); + + test("prepareSingle only offers repositories with a matching upstream", async () => { + const quickPickCalls = []; + let warningCalls = 0; + const service = new UpstreamPullService({}, { + fetchRepositories: async () => [ + { slug: "repo-a", name: "Repo A" }, + { slug: "repo-b", name: "Repo B" }, + ], + upstreamChecker: { + async getRepositoryUpstreamState(_workspace, repo) { + return { + groupedUpstreams: new Map([ + ["python", repo === "repo-b" ? [{ name: "PyPI", is_active: true }] : []], + ]), + }; + }, + }, + showQuickPick: async (items) => { + quickPickCalls.push(items); + return items[0]; + }, + showErrorMessage: async () => {}, + showInformationMessage: async () => {}, + showWarningMessage: async () => { + warningCalls += 1; + }, + }); + + const prepared = await service.prepareSingle({ + workspace: "workspace", + repositoryHint: "repo-b", + dependency: { + name: "requests", + version: "2.31.0", + format: "python", + cloudsmithStatus: "NOT_FOUND", + }, + }); + + assert.ok(prepared); + assert.strictEqual(prepared.repository.slug, "repo-b"); + assert.strictEqual(prepared.plan.pullableDependencies.length, 1); + assert.strictEqual(quickPickCalls.length, 1); + assert.strictEqual(quickPickCalls[0].length, 1); + assert.strictEqual(quickPickCalls[0][0].label, "repo-b"); + assert.match(quickPickCalls[0][0].detail, /Python upstream \(PyPI\)/); + assert.strictEqual(warningCalls, 0); + }); +}); diff --git a/util/registryEndpoints.js b/util/registryEndpoints.js new file mode 100644 index 0000000..3475759 --- /dev/null +++ b/util/registryEndpoints.js @@ -0,0 +1,600 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const { + canonicalFormat, + sanitizePackageNameInput, +} = require("./packageNameNormalizer"); + +const CLOUDSMITH_HOST_SUFFIX = ".cloudsmith.io"; +const MAX_REGISTRY_VALUE_LENGTH = 4096; + +const UNSUPPORTED_PULL_FORMATS = new Set([ + "alpine", + "conda", + "deb", + "generic", + "huggingface", + "raw", + "rpm", +]); + +const DOCKER_MANIFEST_ACCEPT = + "application/vnd.docker.distribution.manifest.v2+json, " + + "application/vnd.docker.distribution.manifest.list.v2+json, " + + "application/vnd.oci.image.manifest.v1+json, " + + "application/vnd.oci.image.index.v1+json"; + +function formatForEcosystem(ecosystemOrFormat) { + const normalized = canonicalFormat(ecosystemOrFormat); + return normalized || null; +} + +function formatForDependency(dependency) { + return formatForEcosystem(dependency && (dependency.format || dependency.ecosystem)); +} + +function isPullUnsupportedFormat(format) { + const normalized = formatForEcosystem(format); + return Boolean(normalized && UNSUPPORTED_PULL_FORMATS.has(normalized)); +} + +function encodePathSegment(value) { + const normalized = String(value == null ? "" : value) + .replace(/\0/g, "") + .trim(); + + if (!normalized || normalized.length > MAX_REGISTRY_VALUE_LENGTH) { + return ""; + } + + if (normalized === ".") { + return "%2E"; + } + + if (normalized === "..") { + return "%2E%2E"; + } + + return encodeURIComponent(normalized); +} + +function encodePath(value) { + return String(value == null ? "" : value) + .replace(/\0/g, "") + .trim() + .split("/") + .filter(Boolean) + .map((segment) => encodePathSegment(segment)) + .join("/"); +} + +function normalizePythonName(name) { + return sanitizePackageNameInput(name).toLowerCase().replace(/[-_.]+/g, "-"); +} + +function encodeGoModulePath(modulePath) { + return [...String(modulePath || "")] + .map((character) => { + if (character === "!") { + return "!!"; + } + if (character >= "A" && character <= "Z") { + return `!${character.toLowerCase()}`; + } + return character; + }) + .join(""); +} + +function cargoIndexPath(crateName) { + const normalized = String(crateName || "").trim().toLowerCase(); + if (!normalized) { + return null; + } + + if (normalized.length <= 2) { + return encodePathSegment(normalized); + } + + if (normalized.length === 3) { + return `1/${encodePathSegment(normalized)}`; + } + + return [ + encodePathSegment(normalized.slice(0, 2)), + encodePathSegment(normalized.slice(2, 4)), + encodePathSegment(normalized), + ].join("/"); +} + +function buildNpmPackagePath(name) { + const rawName = sanitizePackageNameInput(name); + if (!rawName) { + return null; + } + + if (!rawName.startsWith("@")) { + if (rawName.includes("/")) { + return null; + } + + const encodedName = encodePathSegment(rawName); + return { + segments: [encodedName], + tarballBaseName: encodedName, + }; + } + + const separatorIndex = rawName.indexOf("/"); + if ( + separatorIndex <= 1 + || separatorIndex === rawName.length - 1 + || rawName.indexOf("/", separatorIndex + 1) !== -1 + ) { + return null; + } + + const scope = rawName.slice(0, separatorIndex); + const packageName = rawName.slice(separatorIndex + 1); + + return { + segments: [encodePathSegment(scope), encodePathSegment(packageName)], + tarballBaseName: encodePathSegment(packageName), + }; +} + +function buildMavenCoordinates(dependency) { + const name = sanitizePackageNameInput(dependency && dependency.name); + const version = String(dependency && dependency.version || "") + .replace(/\0/g, "") + .trim(); + const coordinates = name.split(":", 3); + + if (coordinates.length < 2 || !version) { + return null; + } + + const groupId = coordinates[0].trim(); + const artifactId = coordinates[1].trim(); + if (!groupId || !artifactId) { + return null; + } + + const groupPath = groupId + .split(".") + .filter(Boolean) + .map((segment) => encodePathSegment(segment)) + .join("/"); + + if (!groupPath) { + return null; + } + + return { + groupPath, + artifactId: encodePathSegment(artifactId), + version: encodePathSegment(version), + }; +} + +function buildComposerCoordinates(name) { + const rawName = sanitizePackageNameInput(name); + const separatorIndex = rawName.indexOf("/"); + if ( + separatorIndex <= 0 + || separatorIndex === rawName.length - 1 + || rawName.indexOf("/", separatorIndex + 1) !== -1 + ) { + return null; + } + + const vendor = rawName.slice(0, separatorIndex); + const packageName = rawName.slice(separatorIndex + 1); + + return { + vendor: encodePathSegment(vendor), + package: encodePathSegment(packageName), + packageName: `${vendor}/${packageName}`, + }; +} + +function buildSwiftCoordinates(name) { + const parts = sanitizePackageNameInput(name).split("/").filter(Boolean); + if (parts.length < 2) { + return null; + } + + return { + scope: parts.slice(0, -1).map((part) => encodePathSegment(part)).join("/"), + name: encodePathSegment(parts[parts.length - 1]), + }; +} + +function buildRegistryTriggerPlan(workspace, repo, dependency) { + const format = formatForDependency(dependency); + if (!format || isPullUnsupportedFormat(format)) { + return null; + } + + const safeWorkspace = encodePathSegment(workspace); + const safeRepo = encodePathSegment(repo); + const version = encodePathSegment(dependency && dependency.version); + + switch (format) { + case "maven": { + const coordinates = buildMavenCoordinates(dependency); + if (!coordinates) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/maven/${coordinates.groupPath}/${coordinates.artifactId}/${coordinates.version}/${coordinates.artifactId}-${coordinates.version}.pom`, + headers: {}, + }, + }; + } + case "npm": { + const packagePath = buildNpmPackagePath(dependency && dependency.name); + if (!packagePath || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://npm.cloudsmith.io/${safeWorkspace}/${safeRepo}/${packagePath.segments.join("/")}/-/${packagePath.tarballBaseName}-${version}.tgz`, + headers: {}, + }, + }; + } + case "python": { + const normalizedName = normalizePythonName(dependency && dependency.name); + if (!normalizedName) { + return null; + } + return { + format, + strategy: "python-simple-index", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/python/simple/${encodePathSegment(normalizedName)}/`, + headers: {}, + }, + }; + } + case "go": { + const modulePath = encodeGoModulePath(String(dependency && dependency.name || "").trim()); + if (!modulePath || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://golang.cloudsmith.io/${safeWorkspace}/${safeRepo}/${modulePath}/@v/${version}.info`, + headers: {}, + }, + }; + } + case "cargo": { + const indexPath = cargoIndexPath(dependency && dependency.name); + if (!indexPath) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://cargo.cloudsmith.io/${safeWorkspace}/${safeRepo}/${indexPath}`, + headers: {}, + }, + }; + } + case "ruby": { + const name = encodePathSegment(dependency && dependency.name); + if (!name || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/ruby/gems/${name}-${version}.gem`, + headers: {}, + }, + }; + } + case "nuget": { + const name = encodePathSegment(dependency && dependency.name); + if (!name || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://nuget.cloudsmith.io/${safeWorkspace}/${safeRepo}/v3/package/${name}/${version}/${name}.${version}.nupkg`, + headers: {}, + }, + }; + } + case "docker": { + const image = encodePath(dependency && dependency.name); + if (!image || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://docker.cloudsmith.io/v2/${safeWorkspace}/${safeRepo}/${image}/manifests/${version}`, + headers: { + Accept: DOCKER_MANIFEST_ACCEPT, + }, + }, + }; + } + case "helm": { + const name = encodePathSegment(dependency && dependency.name); + if (!name || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/helm/charts/${name}-${version}.tgz`, + headers: {}, + }, + }; + } + case "dart": { + const name = encodePathSegment(dependency && dependency.name); + if (!name) { + return null; + } + return { + format, + strategy: "dart-api", + request: { + method: "GET", + url: `https://dart.cloudsmith.io/${safeWorkspace}/${safeRepo}/api/packages/${name}`, + headers: {}, + }, + }; + } + case "composer": { + const coordinates = buildComposerCoordinates(dependency && dependency.name); + if (!coordinates) { + return null; + } + return { + format, + strategy: "composer-p2", + packageName: coordinates.packageName, + request: { + method: "GET", + url: `https://composer.cloudsmith.io/${safeWorkspace}/${safeRepo}/p2/${coordinates.vendor}/${coordinates.package}.json`, + headers: {}, + }, + }; + } + case "hex": { + const name = encodePathSegment(dependency && dependency.name); + if (!name || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/hex/tarballs/${name}-${version}.tar`, + headers: {}, + }, + }; + } + case "swift": { + const coordinates = buildSwiftCoordinates(dependency && dependency.name); + if (!coordinates || !coordinates.scope || !coordinates.name || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/swift/${coordinates.scope}/${coordinates.name}/${version}.zip`, + headers: {}, + }, + }; + } + default: + return null; + } +} + +function isTrustedCloudsmithHost(host) { + const normalizedHost = String(host || "").trim().toLowerCase(); + return normalizedHost === "cloudsmith.io" || normalizedHost.endsWith(CLOUDSMITH_HOST_SUFFIX); +} + +function isTrustedRegistryUrl(candidateUrl) { + try { + const parsed = new URL(candidateUrl); + return parsed.protocol === "https:" && isTrustedCloudsmithHost(parsed.host); + } catch { + return false; + } +} + +function resolveAndValidateRegistryUrl(candidate, baseUrl) { + if (!candidate) { + return null; + } + + let resolved; + try { + resolved = new URL(candidate, baseUrl); + } catch { + return null; + } + + if (!isTrustedRegistryUrl(resolved.toString())) { + return null; + } + + return resolved.toString(); +} + +function collectHrefValues(html) { + const hrefs = []; + const pattern = /]*\bhref\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/gi; + let match = pattern.exec(String(html || "")); + + while (match) { + hrefs.push(match[1] || match[2] || match[3] || ""); + match = pattern.exec(String(html || "")); + } + + return hrefs.filter(Boolean); +} + +function scorePythonArtifact(url, version) { + const normalizedVersion = String(version || "").trim().toLowerCase(); + const fileName = decodeURIComponent(String(url || "").split("/").pop() || "").toLowerCase(); + + if (!fileName) { + return -1; + } + + let score = 0; + if (normalizedVersion) { + if (!fileName.includes(normalizedVersion)) { + return -1; + } + score += 10; + } + + if (fileName.endsWith(".whl")) { + score += 2; + } else if (fileName.endsWith(".tar.gz") || fileName.endsWith(".zip")) { + score += 1; + } + + return score; +} + +function findPythonDistributionUrl(html, version, baseUrl) { + const candidates = collectHrefValues(html) + .map((href) => resolveAndValidateRegistryUrl(href, baseUrl)) + .filter(Boolean) + .map((url) => ({ + url, + score: scorePythonArtifact(url, version), + })) + .filter((candidate) => candidate.score >= 0) + .sort((left, right) => right.score - left.score || left.url.localeCompare(right.url)); + + return candidates.length > 0 ? candidates[0].url : null; +} + +function parseDartArchiveUrl(body, version, baseUrl) { + let payload; + try { + payload = JSON.parse(String(body || "")); + } catch { + return null; + } + + const wantedVersion = String(version || "").trim(); + const candidates = []; + + if (payload && payload.latest && payload.latest.version === wantedVersion && payload.latest.archive_url) { + candidates.push(payload.latest.archive_url); + } + + if (Array.isArray(payload && payload.versions)) { + for (const entry of payload.versions) { + if (entry && entry.version === wantedVersion && entry.archive_url) { + candidates.push(entry.archive_url); + } + } + } else if (payload && payload.versions && typeof payload.versions === "object") { + const entry = payload.versions[wantedVersion]; + if (entry && entry.archive_url) { + candidates.push(entry.archive_url); + } + } + + if (payload && payload.version === wantedVersion && payload.archive_url) { + candidates.push(payload.archive_url); + } + + for (const candidate of candidates) { + const resolved = resolveAndValidateRegistryUrl(candidate, baseUrl); + if (resolved) { + return resolved; + } + } + + return null; +} + +function parseComposerDistUrl(body, packageName, version, baseUrl) { + let payload; + try { + payload = JSON.parse(String(body || "")); + } catch { + return null; + } + + const entries = []; + const normalizedPackageName = sanitizePackageNameInput(packageName); + + if (payload && payload.packages && typeof payload.packages === "object") { + if (Array.isArray(payload.packages[normalizedPackageName])) { + entries.push(...payload.packages[normalizedPackageName]); + } else { + for (const value of Object.values(payload.packages)) { + if (Array.isArray(value)) { + entries.push(...value); + } + } + } + } + + if (Array.isArray(payload)) { + entries.push(...payload); + } + + const matchedEntry = entries.find((entry) => entry && entry.version === version) + || entries.find(Boolean); + const distUrl = matchedEntry + && matchedEntry.dist + && typeof matchedEntry.dist === "object" + ? matchedEntry.dist.url + : null; + + return resolveAndValidateRegistryUrl(distUrl, baseUrl); +} + +module.exports = { + buildRegistryTriggerPlan, + findPythonDistributionUrl, + formatForDependency, + isPullUnsupportedFormat, + isTrustedRegistryUrl, + parseComposerDistUrl, + parseDartArchiveUrl, + resolveAndValidateRegistryUrl, +}; diff --git a/util/upstreamChecker.js b/util/upstreamChecker.js index 354693c..943de51 100644 --- a/util/upstreamChecker.js +++ b/util/upstreamChecker.js @@ -1,6 +1,4 @@ -// Upstream proxy resolution checker. -// Provides a "what if I pull this?" dry run for packages that don't exist locally. - +// Copyright 2026 Cloudsmith Ltd. All rights reserved. const { CloudsmithAPI } = require("./cloudsmithAPI"); const { CredentialManager } = require("./credentialManager"); const { SearchQueryBuilder } = require("./searchQueryBuilder"); @@ -156,6 +154,19 @@ function isAbortError(error) { return error && (error.name === "AbortError" || error.code === "ABORT_ERR"); } +function getActiveUpstreamsFromRepositoryState(state, format) { + if (!state || !(state.groupedUpstreams instanceof Map)) { + return []; + } + + const upstreams = state.groupedUpstreams.get(format); + if (!Array.isArray(upstreams)) { + return []; + } + + return upstreams.filter((upstream) => upstream && upstream.is_active !== false); +} + function getUpstreamRequestOptions(apiKey, signal) { const headers = { accept: "application/json", @@ -450,6 +461,11 @@ class UpstreamChecker { return state.upstreams; } + async getActiveRepositoryUpstreamsForFormat(workspace, repo, format, options = {}) { + const state = await this.getRepositoryUpstreamState(workspace, repo, options); + return getActiveUpstreamsFromRepositoryState(state, format); + } + /** * Orchestrate a full upstream resolution preview. * Checks local existence and upstream configs for a package preview. @@ -492,7 +508,7 @@ class UpstreamChecker { } _isCacheObjectRecord(value) { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); + return isCacheObjectRecord(value); } _logRepositoryUpstreamCacheError(action, workspace, repo, error) { @@ -749,98 +765,15 @@ class UpstreamChecker { } _getRequestOptions(apiKey, signal) { - const headers = { - accept: "application/json", - "content-type": "application/json", - }; - - if (apiKey) { - headers["X-Api-Key"] = apiKey; - } - - const requestOptions = { - method: "GET", - headers, - }; - - if (signal) { - requestOptions.signal = signal; - } - - return requestOptions; + return getUpstreamRequestOptions(apiKey, signal); } _isAbortError(error) { - return error && (error.name === "AbortError" || error.code === "ABORT_ERR"); + return isAbortError(error); } _isWarningWorthyFormatError(message) { - const normalized = typeof message === "string" ? message.toLowerCase() : ""; - if (!normalized) { - return true; - } - - const benignKeywords = [ - "response status: 404", - "not found", - "unsupported", - "not applicable", - "unknown format", - "no upstream", - "does not exist", - ]; - if (benignKeywords.some((keyword) => normalized.includes(keyword))) { - return false; - } - - const statusMatch = normalized.match(/response status:\s*(\d{3})/); - if (statusMatch) { - const statusCode = Number(statusMatch[1]); - if ( - statusCode === 401 || - statusCode === 403 || - statusCode === 407 || - statusCode === 408 || - statusCode === 429 - ) { - return true; - } - if (statusCode >= 500) { - return true; - } - if (statusCode >= 400) { - return true; - } - } - - const warningKeywords = [ - "blocked ", - "redirect", - "fetch failed", - "network", - "timed out", - "timeout", - "unauthorized", - "forbidden", - "permission", - "access denied", - "server error", - "bad gateway", - "service unavailable", - "gateway timeout", - "econn", - "enotfound", - "eai_again", - "socket", - "tls", - "certificate", - ]; - - if (warningKeywords.some((keyword) => normalized.includes(keyword))) { - return true; - } - - return true; + return isWarningWorthyUpstreamFormatError(message); } } @@ -857,6 +790,7 @@ async function getUpstreamDataForFormats(context, workspace, repo, formats, opti module.exports = { getAllUpstreamData, getUpstreamDataForFormats, + getActiveUpstreamsFromRepositoryState, isBenignUpstreamFormatError, SUPPORTED_UPSTREAM_FORMATS, UpstreamChecker, diff --git a/util/upstreamGapAnalyzer.js b/util/upstreamGapAnalyzer.js new file mode 100644 index 0000000..2dac342 --- /dev/null +++ b/util/upstreamGapAnalyzer.js @@ -0,0 +1,213 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const { canonicalFormat, normalizePackageName } = require("./packageNameNormalizer"); +const { normalizeUpstreamFormat } = require("./upstreamFormats"); +const { UpstreamChecker } = require("./upstreamChecker"); + +const UPSTREAM_REPO_CONCURRENCY = 5; + +function getUncoveredDependencyKey(dependency) { + const format = canonicalFormat(dependency && (dependency.format || dependency.ecosystem)); + const normalizedName = normalizePackageName(dependency && dependency.name, format); + const version = String(dependency && dependency.version || "").trim().toLowerCase(); + + if (!format || !normalizedName) { + return null; + } + + return `${format}:${normalizedName}:${version}`; +} + +function formatLabel(format) { + const normalized = String(format || "").trim(); + if (!normalized) { + return "package"; + } + if (normalized === "npm") { + return "npm"; + } + if (normalized === "python") { + return "PyPI"; + } + if (normalized === "go") { + return "Go"; + } + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function buildProxyLabel(upstream, format) { + const configuredName = String(upstream && upstream.name || "").trim(); + if (!configuredName) { + return `${formatLabel(format)} proxy`; + } + return configuredName.toLowerCase().includes("proxy") + ? configuredName + : `${configuredName} proxy`; +} + +function buildReachableDetail(snapshot, upstream, format) { + return `${buildProxyLabel(upstream, format)} on ${snapshot.repo}`; +} + +function classifyDependency(dependency, snapshots) { + const key = getUncoveredDependencyKey(dependency); + const format = canonicalFormat(dependency && (dependency.format || dependency.ecosystem)); + if (!key || !format) { + return { + upstreamStatus: "unreachable", + upstreamDetail: "Not available through Cloudsmith", + }; + } + + const upstreamFormat = normalizeUpstreamFormat(format); + if (!upstreamFormat) { + return { + upstreamStatus: "unreachable", + upstreamDetail: "Not available through Cloudsmith", + }; + } + + for (const snapshot of snapshots) { + const formatUpstreams = Array.isArray(snapshot.groupedUpstreams.get(upstreamFormat)) + ? snapshot.groupedUpstreams.get(upstreamFormat) + : []; + const activeUpstream = formatUpstreams.find((upstream) => upstream.is_active !== false); + if (!activeUpstream) { + continue; + } + + return { + upstreamStatus: "reachable", + upstreamDetail: buildReachableDetail(snapshot, activeUpstream, upstreamFormat), + }; + } + + return { + upstreamStatus: "no_proxy", + upstreamDetail: `No upstream proxy configured for ${upstreamFormat}`, + }; +} + +function buildGapPatch(uncoveredDependencies, snapshots) { + const patchMap = new Map(); + + for (const dependency of Array.isArray(uncoveredDependencies) ? uncoveredDependencies : []) { + if (dependency.cloudsmithStatus !== "NOT_FOUND") { + continue; + } + + const key = getUncoveredDependencyKey(dependency); + if (!key || patchMap.has(key)) { + continue; + } + + patchMap.set(key, classifyDependency(dependency, snapshots)); + } + + return patchMap; +} + +function applyGapPatch(dependencies, patchMap) { + return (Array.isArray(dependencies) ? dependencies : []).map((dependency) => { + const key = getUncoveredDependencyKey(dependency); + if (!key || !patchMap.has(key)) { + return dependency; + } + + const gap = patchMap.get(key); + return { + ...dependency, + upstreamStatus: gap.upstreamStatus, + upstreamDetail: gap.upstreamDetail, + }; + }); +} + +async function analyzeUpstreamGaps(uncoveredDependencies, workspace, repositories, options = {}) { + const onProgress = typeof options.onProgress === "function" ? options.onProgress : null; + const cancellationToken = options.cancellationToken || null; + const upstreamChecker = options.upstreamChecker || new UpstreamChecker(options.context); + const repositoriesToInspect = Array.isArray(repositories) + ? repositories.map((repo) => String(repo || "").trim()).filter(Boolean) + : []; + + if (repositoriesToInspect.length === 0) { + const emptyPatch = buildGapPatch(uncoveredDependencies, []); + if (onProgress && emptyPatch.size > 0) { + onProgress(new Map(emptyPatch), { + completed: 0, + total: 0, + workspace, + stage: "upstream", + }); + } + return applyGapPatch(uncoveredDependencies, emptyPatch); + } + + const repoUpstreamStates = new Map(); + let completed = 0; + + await runPromisePool(repositoriesToInspect, UPSTREAM_REPO_CONCURRENCY, async (repo) => { + if (cancellationToken && cancellationToken.isCancellationRequested) { + return; + } + + const state = await upstreamChecker.getRepositoryUpstreamState(workspace, repo); + repoUpstreamStates.set(repo, { + repo, + groupedUpstreams: state && state.groupedUpstreams instanceof Map + ? state.groupedUpstreams + : new Map(), + }); + + completed += 1; + if (onProgress) { + onProgress(new Map(), { + completed, + total: repositoriesToInspect.length, + workspace, + stage: "upstream", + }); + } + }); + + const snapshots = repositoriesToInspect + .filter((repo) => repoUpstreamStates.has(repo)) + .map((repo) => repoUpstreamStates.get(repo)); + + const patchMap = buildGapPatch(uncoveredDependencies, snapshots); + if (onProgress && patchMap.size > 0) { + onProgress(new Map(patchMap), { + completed, + total: repositoriesToInspect.length, + workspace, + stage: "upstream", + }); + } + return applyGapPatch(uncoveredDependencies, patchMap); +} + +async function runPromisePool(items, concurrency, worker) { + const workers = []; + let index = 0; + const size = Math.max(1, Math.min(concurrency, items.length || 1)); + + for (let workerIndex = 0; workerIndex < size; workerIndex += 1) { + workers.push((async () => { + while (index < items.length) { + const item = items[index]; + index += 1; + if (item === undefined) { + break; + } + await worker(item); + } + })()); + } + + await Promise.all(workers); +} + +module.exports = { + analyzeUpstreamGaps, + getUncoveredDependencyKey, +}; diff --git a/util/upstreamPullService.js b/util/upstreamPullService.js new file mode 100644 index 0000000..cac515f --- /dev/null +++ b/util/upstreamPullService.js @@ -0,0 +1,1231 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const vscode = require("vscode"); +const { CloudsmithAPI } = require("./cloudsmithAPI"); +const { CredentialManager } = require("./credentialManager"); +const { PaginatedFetch } = require("./paginatedFetch"); +const { + buildRegistryTriggerPlan, + findPythonDistributionUrl, + formatForDependency, + isPullUnsupportedFormat, + isTrustedRegistryUrl, + parseComposerDistUrl, + parseDartArchiveUrl, + resolveAndValidateRegistryUrl, +} = require("./registryEndpoints"); +const { canonicalFormat } = require("./packageNameNormalizer"); +const { UpstreamChecker } = require("./upstreamChecker"); +const { normalizeUpstreamFormat } = require("./upstreamFormats"); + +const MAX_CONCURRENT_PULLS = 5; +const INITIAL_AUTH_PROBE_CONCURRENCY = 3; +const MAX_REGISTRY_REDIRECTS = 5; +const REQUEST_TIMEOUT_MS = 30 * 1000; +const WORKSPACE_REPOSITORY_PAGE_SIZE = 500; + +const PULL_STATUS = Object.freeze({ + PENDING: "pending", + PULLING: "pulling", + CACHED: "cached", + ALREADY_EXISTS: "exists", + NOT_FOUND: "not_found", + AUTH_FAILED: "auth_failed", + FORMAT_MISMATCH: "mismatch", + ERROR: "error", + SKIPPED: "skipped", +}); + +const PULL_SKIP_REASON = Object.freeze({ + NO_ACTIVE_UPSTREAM: "no_active_upstream", + NO_PULL_SUPPORT: "no_pull_support", + NO_TRIGGER_URL: "no_trigger_url", +}); + +class UpstreamPullService { + constructor(context, options = {}) { + this.context = context; + this._api = options.api || new CloudsmithAPI(context); + this._credentialManager = options.credentialManager || new CredentialManager(context); + this._fetchImpl = options.fetchImpl || fetch; + this._fetchRepositories = options.fetchRepositories || this._fetchWorkspaceRepositories.bind(this); + this._showQuickPick = options.showQuickPick || vscode.window.showQuickPick.bind(vscode.window); + this._showErrorMessage = options.showErrorMessage || vscode.window.showErrorMessage.bind(vscode.window); + this._showInformationMessage = options.showInformationMessage || vscode.window.showInformationMessage.bind(vscode.window); + this._showWarningMessage = options.showWarningMessage || vscode.window.showWarningMessage.bind(vscode.window); + this._upstreamChecker = options.upstreamChecker || new UpstreamChecker(context); + } + + async run(options) { + const prepared = await this.prepare(options); + if (!prepared) { + return null; + } + + const execution = await this.execute(prepared, options); + if (!execution) { + return null; + } + + return { + ...prepared, + ...execution, + }; + } + + async prepare({ + workspace, + repositoryHint, + dependencies, + }) { + const uncoveredDependencies = dedupePullDependencies( + (Array.isArray(dependencies) ? dependencies : []) + .filter((dependency) => dependency && dependency.cloudsmithStatus !== "FOUND") + ); + + if (!workspace) { + await this._showErrorMessage("Run a dependency scan against a Cloudsmith workspace first."); + return null; + } + + if (uncoveredDependencies.length === 0) { + await this._showInformationMessage("No uncovered dependencies are available to pull."); + return null; + } + + const projectFormats = [...new Set( + uncoveredDependencies + .map((dependency) => normalizeUpstreamFormat(formatForDependency(dependency))) + .filter(Boolean) + )]; + + if (projectFormats.length === 0) { + await this._showInformationMessage( + "Pull-through caching is not available for the uncovered dependency formats in this project." + ); + return null; + } + + let repositories; + try { + repositories = await this._fetchRepositories(workspace); + } catch (error) { + const message = error && error.message ? error.message : "Could not fetch workspace repositories."; + await this._showErrorMessage(message); + return null; + } + + const repositoryMatches = await this._findMatchingRepositories(workspace, repositories, projectFormats); + if (repositoryMatches.length === 0) { + await this._showInformationMessage( + `No repositories have upstream proxies configured for the dependency formats in this project (${formatListLabel(projectFormats)}). Configure an upstream proxy in Cloudsmith to enable pull-through caching.` + ); + return null; + } + + const orderedMatches = sortRepositoryMatches(repositoryMatches, repositoryHint); + const selected = await this._showQuickPick( + orderedMatches.map((match) => ({ + label: match.repo.slug || match.repo.name, + description: match.repo.name && match.repo.name !== match.repo.slug ? match.repo.name : "", + detail: `${formatListLabel(match.activeFormats)} upstream${match.activeFormats.length === 1 ? "" : "s"} configured`, + _match: match, + })), + { + placeHolder: "Select a repository to pull dependencies through", + matchOnDescription: true, + matchOnDetail: true, + } + ); + + if (!selected || !selected._match) { + return null; + } + + const repository = selected._match.repo; + const plan = buildPullExecutionPlan( + workspace, + repository.slug, + uncoveredDependencies, + selected._match.activeFormats + ); + + if (plan.pullableDependencies.length === 0) { + await this._showInformationMessage(buildPullPlanErrorMessage(repository.slug, plan)); + return null; + } + + const confirmation = await this._showWarningMessage( + buildPullConfirmationMessage(plan, repository.slug), + { modal: true }, + "Pull dependencies" + ); + + if (confirmation !== "Pull dependencies") { + return null; + } + + return { + workspace, + repository, + plan, + }; + } + + async prepareSingle({ + workspace, + repositoryHint, + dependency, + }) { + const normalizedDependency = normalizeSingleDependency(dependency); + if (!workspace) { + await this._showErrorMessage("Run a dependency scan against a Cloudsmith workspace first."); + return null; + } + + if (!normalizedDependency) { + await this._showWarningMessage("Could not determine the dependency details to pull."); + return null; + } + + const dependencyFormat = normalizeUpstreamFormat(formatForDependency(normalizedDependency)); + if (!dependencyFormat) { + await this._showInformationMessage( + `Pull-through caching is not available for ${formatDisplayName(normalizedDependency.format)} dependencies.` + ); + return null; + } + + let repositories; + try { + repositories = await this._fetchRepositories(workspace); + } catch (error) { + const message = error && error.message ? error.message : "Could not fetch workspace repositories."; + await this._showErrorMessage(message); + return null; + } + + const repositoryMatches = await this._findMatchingRepositories(workspace, repositories, [dependencyFormat]); + if (repositoryMatches.length === 0) { + await this._showInformationMessage( + `No repositories have a ${formatDisplayName(dependencyFormat)} upstream configured. Add one in Cloudsmith to pull this dependency.` + ); + return null; + } + + const orderedMatches = sortRepositoryMatches(repositoryMatches, repositoryHint); + const selected = await this._showQuickPick( + orderedMatches.map((match) => ({ + label: match.repo.slug || match.repo.name, + description: match.repo.name && match.repo.name !== match.repo.slug ? match.repo.name : "", + detail: buildSingleDependencyRepositoryDetail(match, dependencyFormat), + _match: match, + })), + { + placeHolder: `Select a repository to pull ${buildDependencyLabel(normalizedDependency)} through`, + matchOnDescription: true, + matchOnDetail: true, + } + ); + + if (!selected || !selected._match) { + return null; + } + + const repository = selected._match.repo; + const plan = buildPullExecutionPlan( + workspace, + repository.slug, + [normalizedDependency], + selected._match.activeFormats + ); + + if (plan.pullableDependencies.length === 0) { + await this._showInformationMessage(buildPullPlanErrorMessage(repository.slug, plan)); + return null; + } + + return { + workspace, + repository, + plan, + dependency: normalizedDependency, + }; + } + + async execute(prepared, options = {}) { + const apiKey = await this._credentialManager.getApiKey(); + if (!apiKey) { + await this._showErrorMessage("Authentication failed. Check your API key in Cloudsmith settings."); + return null; + } + + const progress = options.progress || null; + const token = options.token || null; + const onStatus = typeof options.onStatus === "function" ? options.onStatus : null; + const queue = prepared.plan.pullableDependencies.slice(); + let nextDependencyIndex = 0; + const details = []; + const counts = createResultCounts(prepared.plan.pullableDependencies.length); + const state = { + authFailureCount: 0, + nonAuthOutcomeCount: 0, + stopForAuthFailure: false, + canceled: false, + allowedConcurrency: Math.min( + prepared.plan.pullableDependencies.length || 1, + INITIAL_AUTH_PROBE_CONCURRENCY + ), + expandedConcurrency: false, + }; + const pending = new Set(); + let activeCount = 0; + let launchedCount = 0; + + const takeNextDependency = () => { + if (nextDependencyIndex >= queue.length) { + return null; + } + + const dependency = queue[nextDependencyIndex]; + nextDependencyIndex += 1; + return dependency; + }; + + const processNext = async () => { + if (token && token.isCancellationRequested) { + state.canceled = true; + return; + } + + if (state.stopForAuthFailure) { + return; + } + + const dependency = takeNextDependency(); + if (!dependency) { + return; + } + + activeCount += 1; + + try { + const pullingDetail = { + dependency, + status: PULL_STATUS.PULLING, + errorMessage: null, + requestUrl: buildPullRequestUrl(prepared.workspace, prepared.repository.slug, dependency), + }; + if (onStatus) { + await onStatus(pullingDetail); + } + + const result = await this._pullDependency( + prepared.workspace, + prepared.repository.slug, + dependency, + apiKey, + token + ); + + if (result.canceled) { + state.canceled = true; + return; + } + + details.push(result); + updateResultCounts(counts, result); + + if (result.status === PULL_STATUS.AUTH_FAILED) { + state.authFailureCount += 1; + } else { + state.nonAuthOutcomeCount += 1; + } + + if ( + result.status === PULL_STATUS.AUTH_FAILED + && state.authFailureCount >= INITIAL_AUTH_PROBE_CONCURRENCY + && state.nonAuthOutcomeCount === 0 + ) { + state.stopForAuthFailure = true; + } + + if ( + !state.expandedConcurrency + && state.nonAuthOutcomeCount > 0 + && state.allowedConcurrency < MAX_CONCURRENT_PULLS + ) { + state.allowedConcurrency = Math.min(MAX_CONCURRENT_PULLS, counts.total); + state.expandedConcurrency = true; + } + + if (progress) { + progress.report({ + message: buildProgressMessage(counts), + increment: counts.total > 0 ? 100 / counts.total : 100, + }); + } + + if (onStatus) { + await onStatus(result); + } + } finally { + activeCount -= 1; + fillConcurrency(); + } + }; + + const fillConcurrency = () => { + while ( + activeCount < state.allowedConcurrency + && (state.expandedConcurrency || launchedCount < INITIAL_AUTH_PROBE_CONCURRENCY) + && nextDependencyIndex < queue.length + && !(token && token.isCancellationRequested) + && !state.stopForAuthFailure + ) { + launchedCount += 1; + const promise = processNext(); + pending.add(promise); + promise.finally(() => pending.delete(promise)); + } + }; + + fillConcurrency(); + + while (pending.size > 0) { + await Promise.race([...pending]); + } + + if (state.stopForAuthFailure) { + while (nextDependencyIndex < queue.length) { + const dependency = queue[nextDependencyIndex]; + nextDependencyIndex += 1; + details.push({ + dependency, + status: PULL_STATUS.AUTH_FAILED, + errorMessage: "Skipped after repeated authentication failures.", + requestUrl: buildPullRequestUrl(prepared.workspace, prepared.repository.slug, dependency), + networkError: false, + }); + } + recomputeResultCounts(counts, details); + await this._showErrorMessage("Authentication failed. Check your API key in Cloudsmith settings."); + } else if (state.canceled) { + return { + canceled: true, + }; + } else if ( + counts.completed > 0 + && counts.completed === counts.errors + && counts.networkErrors === counts.errors + ) { + await this._showErrorMessage("Cannot reach the Cloudsmith registry. Check your network connection."); + } + + return { + canceled: false, + pullResult: { + total: counts.total, + cached: counts.cached, + alreadyExisted: counts.alreadyExisted, + notFound: counts.notFound, + formatMismatched: counts.formatMismatched, + errors: counts.errors, + networkErrors: counts.networkErrors, + authFailed: counts.authFailed, + skipped: counts.skipped, + details, + }, + }; + } + + async _findMatchingRepositories(workspace, repositories, projectFormats) { + const matches = []; + + await runPromisePool(repositories, 5, async (repo) => { + const repoSlug = repo && repo.slug ? repo.slug : null; + if (!repoSlug) { + return; + } + + const state = await this._upstreamChecker.getRepositoryUpstreamState(workspace, repoSlug); + const activeUpstreamsByFormat = new Map(); + const activeFormats = projectFormats.filter((format) => { + const upstreams = state && state.groupedUpstreams instanceof Map + ? state.groupedUpstreams.get(format) + : []; + const activeUpstreams = Array.isArray(upstreams) + ? upstreams.filter((upstream) => upstream && upstream.is_active !== false) + : []; + if (activeUpstreams.length > 0) { + activeUpstreamsByFormat.set(format, activeUpstreams); + return true; + } + return false; + }); + + if (activeFormats.length === 0) { + return; + } + + matches.push({ + repo, + activeFormats, + activeUpstreamsByFormat, + }); + }); + + return matches.sort((left, right) => { + const leftSlug = String(left.repo.slug || left.repo.name || ""); + const rightSlug = String(right.repo.slug || right.repo.name || ""); + return leftSlug.localeCompare(rightSlug, undefined, { sensitivity: "base" }); + }); + } + + async _fetchWorkspaceRepositories(workspace) { + const paginatedFetch = new PaginatedFetch(this._api); + const endpoint = `repos/${workspace}/?sort=name`; + const repositories = []; + let page = 1; + + while (true) { + const result = await paginatedFetch.fetchPage(endpoint, page, WORKSPACE_REPOSITORY_PAGE_SIZE); + if (result.error) { + throw new Error(`Could not fetch workspace repositories. ${result.error}`); + } + + repositories.push(...(Array.isArray(result.data) ? result.data : [])); + + const pageTotal = result.pagination && result.pagination.pageTotal + ? result.pagination.pageTotal + : 1; + if (page >= pageTotal) { + break; + } + page += 1; + } + + return repositories; + } + + async _pullDependency(workspace, repo, dependency, apiKey, token) { + const plan = buildRegistryTriggerPlan(workspace, repo, dependency); + const format = formatForDependency(dependency); + + if (!plan) { + const errorMessage = isPullUnsupportedFormat(format) + ? `Pull-through caching is not supported for ${formatDisplayName(format)} dependencies.` + : `No registry trigger URL is available for ${formatDisplayName(format)} dependencies.`; + + return { + dependency, + status: PULL_STATUS.FORMAT_MISMATCH, + errorMessage, + requestUrl: null, + networkError: false, + }; + } + + const metadataAttempt = await this._requestRegistry(plan.request, apiKey, token); + if (metadataAttempt.canceled) { + return metadataAttempt; + } + + if (plan.strategy === "direct") { + return mapRegistryAttempt(dependency, metadataAttempt, plan.request.url, format); + } + + if (metadataAttempt.statusCode === 401 || metadataAttempt.statusCode === 403) { + return mapRegistryAttempt(dependency, metadataAttempt, plan.request.url, format); + } + + if (metadataAttempt.statusCode === 404) { + return mapRegistryAttempt(dependency, metadataAttempt, plan.request.url, format); + } + + if (metadataAttempt.statusCode < 200 || metadataAttempt.statusCode >= 300) { + return mapRegistryAttempt(dependency, metadataAttempt, plan.request.url, format); + } + + let artifactUrl = null; + if (plan.strategy === "python-simple-index") { + artifactUrl = findPythonDistributionUrl(metadataAttempt.body, dependency.version, plan.request.url); + } else if (plan.strategy === "dart-api") { + artifactUrl = parseDartArchiveUrl(metadataAttempt.body, dependency.version, plan.request.url); + } else if (plan.strategy === "composer-p2") { + artifactUrl = parseComposerDistUrl( + metadataAttempt.body, + plan.packageName || dependency.name, + dependency.version, + plan.request.url + ); + } + + if (!artifactUrl) { + return { + dependency, + status: PULL_STATUS.NOT_FOUND, + errorMessage: missingArtifactMessage(plan.strategy, dependency.version), + requestUrl: plan.request.url, + networkError: false, + }; + } + + const artifactAttempt = await this._requestRegistry( + { + method: "GET", + url: artifactUrl, + headers: {}, + }, + apiKey, + token + ); + + if (artifactAttempt.canceled) { + return artifactAttempt; + } + + return mapRegistryAttempt(dependency, artifactAttempt, artifactUrl, format); + } + + async _requestRegistry(request, apiKey, token) { + const controller = new AbortController(); + let didTimeout = false; + const timeoutHandle = setTimeout(() => { + didTimeout = true; + controller.abort(); + }, REQUEST_TIMEOUT_MS); + + const cancellationDisposable = token && typeof token.onCancellationRequested === "function" + ? token.onCancellationRequested(() => controller.abort()) + : null; + + try { + const response = await this._fetchRegistryResponse( + request, + apiKey, + controller.signal, + 0 + ); + + return { + statusCode: response.status, + body: await response.text(), + }; + } catch (error) { + if (token && token.isCancellationRequested) { + return { canceled: true }; + } + + return { + statusCode: 0, + body: "", + errorMessage: didTimeout + ? "Registry request timed out." + : buildRegistryErrorMessage(request.url, error), + networkError: isNetworkError(error) || didTimeout, + }; + } finally { + clearTimeout(timeoutHandle); + if (cancellationDisposable && typeof cancellationDisposable.dispose === "function") { + cancellationDisposable.dispose(); + } + } + } + + async _fetchRegistryResponse(request, apiKey, signal, redirectCount) { + if (!request || !isTrustedRegistryUrl(request.url)) { + throw new Error("Refused to send Cloudsmith credentials to an untrusted registry host."); + } + + const response = await this._fetchImpl(request.url, { + method: request.method || "GET", + headers: { + Authorization: buildBasicAuthHeader(apiKey), + ...(request.headers || {}), + }, + redirect: "manual", + signal, + }); + + if (!isRedirectStatus(response.status)) { + return response; + } + + if (redirectCount >= MAX_REGISTRY_REDIRECTS) { + throw new Error("Registry request exceeded the redirect limit."); + } + + const location = response.headers && typeof response.headers.get === "function" + ? response.headers.get("location") + : ""; + const redirectUrl = resolveAndValidateRegistryUrl(location, request.url); + if (!redirectUrl || !isTrustedRegistryUrl(redirectUrl)) { + throw new Error("Registry redirect target was rejected."); + } + + return this._fetchRegistryResponse( + { + ...request, + url: redirectUrl, + }, + apiKey, + signal, + redirectCount + 1 + ); + } +} + +function buildPullExecutionPlan(workspace, repo, dependencies, activeUpstreamFormats) { + const normalizedActiveFormats = [...new Set( + (Array.isArray(activeUpstreamFormats) ? activeUpstreamFormats : []) + .map((format) => normalizeUpstreamFormat(format)) + .filter(Boolean) + )]; + + const uniqueDependencies = dedupePullDependencies(dependencies); + const skippedDependencies = []; + const pullableDependencies = []; + + for (const dependency of uniqueDependencies) { + const format = canonicalFormat(formatForDependency(dependency) || dependency.ecosystem || ""); + const triggerPlan = buildRegistryTriggerPlan(workspace, repo, dependency); + + if (isPullUnsupportedFormat(format)) { + skippedDependencies.push({ + dependency, + format, + reason: PULL_SKIP_REASON.NO_PULL_SUPPORT, + message: `Pull-through caching is not supported for ${formatDisplayName(format)} dependencies.`, + }); + continue; + } + + if (!triggerPlan) { + skippedDependencies.push({ + dependency, + format, + reason: PULL_SKIP_REASON.NO_TRIGGER_URL, + message: `No registry trigger URL is available for ${formatDisplayName(format)} dependencies.`, + }); + continue; + } + + if (!normalizedActiveFormats.includes(normalizeUpstreamFormat(format))) { + skippedDependencies.push({ + dependency, + format, + reason: PULL_SKIP_REASON.NO_ACTIVE_UPSTREAM, + message: `No ${formatDisplayName(format)} upstream is configured on this repository.`, + }); + continue; + } + + pullableDependencies.push(dependency); + } + + return { + dependencies: uniqueDependencies, + pullableDependencies, + skippedDependencies, + activeUpstreamFormats: normalizedActiveFormats, + }; +} + +function buildPullConfirmationMessage(plan, repositoryLabel) { + const totalCount = plan.dependencies.length; + const pullableCount = plan.pullableDependencies.length; + const header = plan.skippedDependencies.length > 0 + ? `Pull ${pullableCount} of ${totalCount} dependencies through ${repositoryLabel}?` + : singleFormatPullHeader(plan.pullableDependencies, repositoryLabel); + const pulledLine = buildPullableSummary(plan.pullableDependencies, plan.skippedDependencies.length > 0); + const skippedLine = buildSkippedSummary(plan.skippedDependencies); + + return [ + header, + pulledLine, + skippedLine, + "Packages not already cached will be fetched from the upstream source.", + ].filter(Boolean).join("\n"); +} + +function buildPullPlanErrorMessage(repositoryLabel, plan) { + const noUpstreamFormats = [...new Set( + plan.skippedDependencies + .filter((entry) => entry.reason === PULL_SKIP_REASON.NO_ACTIVE_UPSTREAM) + .map((entry) => entry.format) + .filter(Boolean) + )]; + + if (plan.pullableDependencies.length === 0 && noUpstreamFormats.length > 0) { + return `No ${formatListLabel(noUpstreamFormats)} upstream${noUpstreamFormats.length === 1 ? "" : "s"} are configured on ${repositoryLabel}.`; + } + + return "Pull-through caching is not available for the uncovered dependencies in this project."; +} + +function buildPullSummaryMessage(result, skippedCount) { + const pulledCount = result.cached + result.alreadyExisted; + const parts = [ + `Pulled ${pulledCount} of ${result.total} dependencies.`, + `${result.cached} cached`, + `${result.alreadyExisted} already existed`, + `${result.notFound} not found upstream`, + ]; + + if (skippedCount > 0) { + parts.push(`${skippedCount} skipped`); + } + + if (result.errors > 0) { + parts.push(`${result.errors} errors`); + } + + return `${parts.shift()} ${parts.join(", ")}.`; +} + +function buildProgressMessage(counts) { + const parts = [`Pulling dependencies... ${counts.completed}/${counts.total}`]; + const detail = []; + + if (counts.cached > 0) { + detail.push(`${counts.cached} cached`); + } + if (counts.notFound > 0) { + detail.push(`${counts.notFound} not found`); + } + if (counts.errors > 0) { + detail.push(`${counts.errors} errors`); + } + + if (detail.length > 0) { + parts.push(`(${detail.join(", ")})`); + } + + return parts.join(" "); +} + +function createResultCounts(total) { + return { + total, + completed: 0, + cached: 0, + alreadyExisted: 0, + notFound: 0, + formatMismatched: 0, + errors: 0, + networkErrors: 0, + authFailed: 0, + skipped: 0, + }; +} + +function updateResultCounts(counts, result) { + counts.completed += 1; + switch (result.status) { + case PULL_STATUS.CACHED: + counts.cached += 1; + break; + case PULL_STATUS.ALREADY_EXISTS: + counts.alreadyExisted += 1; + break; + case PULL_STATUS.NOT_FOUND: + counts.notFound += 1; + break; + case PULL_STATUS.FORMAT_MISMATCH: + counts.formatMismatched += 1; + break; + case PULL_STATUS.AUTH_FAILED: + counts.authFailed += 1; + counts.errors += 1; + break; + case PULL_STATUS.SKIPPED: + counts.skipped += 1; + break; + case PULL_STATUS.ERROR: + counts.errors += 1; + if (result.networkError) { + counts.networkErrors += 1; + } + break; + default: + break; + } +} + +function recomputeResultCounts(counts, results) { + const next = createResultCounts(results.length); + for (const result of results) { + updateResultCounts(next, result); + } + + Object.assign(counts, next); +} + +function mapRegistryAttempt(dependency, attempt, requestUrl, format) { + if (attempt.statusCode >= 200 && attempt.statusCode < 300) { + return { + dependency, + status: PULL_STATUS.CACHED, + errorMessage: null, + requestUrl, + networkError: false, + }; + } + + if (attempt.statusCode === 304 || attempt.statusCode === 409) { + return { + dependency, + status: PULL_STATUS.ALREADY_EXISTS, + errorMessage: null, + requestUrl, + networkError: false, + }; + } + + if (attempt.statusCode === 401 || attempt.statusCode === 403) { + return { + dependency, + status: PULL_STATUS.AUTH_FAILED, + errorMessage: "Authentication failed.", + requestUrl, + networkError: false, + }; + } + + if (attempt.statusCode === 404) { + return { + dependency, + status: PULL_STATUS.NOT_FOUND, + errorMessage: defaultNotFoundMessage(format), + requestUrl, + networkError: false, + }; + } + + if (attempt.statusCode === 0) { + return { + dependency, + status: PULL_STATUS.ERROR, + errorMessage: attempt.errorMessage || "Registry request failed.", + requestUrl, + networkError: Boolean(attempt.networkError), + }; + } + + return { + dependency, + status: PULL_STATUS.ERROR, + errorMessage: `Registry request returned HTTP ${attempt.statusCode}.`, + requestUrl, + networkError: false, + }; +} + +function defaultNotFoundMessage(format) { + switch (format) { + case "docker": + return "Image manifest not found upstream."; + case "go": + return "Go module metadata not found upstream."; + case "cargo": + return "Cargo index entry not found upstream."; + case "helm": + return "Chart archive not found upstream."; + default: + return "Package not found upstream."; + } +} + +function missingArtifactMessage(strategy, version) { + switch (strategy) { + case "python-simple-index": + return `No distribution file was found for version ${version}.`; + case "dart-api": + return `No Dart archive URL was found for version ${version}.`; + case "composer-p2": + return `No Composer dist URL was found for version ${version}.`; + default: + return "No downloadable artifact was found."; + } +} + +function buildRegistryErrorMessage(url, error) { + if (isNetworkError(error)) { + return "Cannot reach the Cloudsmith registry. Check your network connection."; + } + + const host = safeHost(url); + const message = error && error.message ? error.message : "Registry request failed."; + return host ? `${message} (${host})` : message; +} + +function isNetworkError(error) { + const code = error && ( + error.code + || (error.cause && error.cause.code) + || (error.errno) + ); + + if (["ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "EHOSTUNREACH", "ETIMEDOUT"].includes(code)) { + return true; + } + + const message = String(error && error.message || "").toLowerCase(); + return message.includes("fetch failed") + || message.includes("network") + || message.includes("timed out") + || message.includes("econnrefused") + || message.includes("enotfound"); +} + +function buildBasicAuthHeader(apiKey) { + return `Basic ${Buffer.from(`token:${apiKey}`).toString("base64")}`; +} + +function isRedirectStatus(statusCode) { + return Number.isInteger(statusCode) && statusCode >= 300 && statusCode < 400; +} + +function safeHost(url) { + try { + return new URL(url).host; + } catch { + return ""; + } +} + +function buildPullRequestUrl(workspace, repo, dependency) { + const plan = buildRegistryTriggerPlan(workspace, repo, dependency); + return plan && plan.request ? plan.request.url : null; +} + +function dedupePullDependencies(dependencies) { + const unique = new Map(); + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + const key = pullDependencyKey(dependency); + if (!unique.has(key)) { + unique.set(key, dependency); + } + } + + return [...unique.values()]; +} + +function pullDependencyKey(dependency) { + return [ + String(canonicalFormat(dependency && (dependency.format || dependency.ecosystem)) || "").toLowerCase(), + String(dependency && dependency.name || "").toLowerCase(), + String(dependency && dependency.version || "").toLowerCase(), + ].join(":"); +} + +function singleFormatPullHeader(dependencies, repositoryLabel) { + const formats = [...new Set( + dependencies.map((dependency) => canonicalFormat(formatForDependency(dependency))).filter(Boolean) + )]; + + if (formats.length === 1) { + return `Pull ${dependencies.length} ${formatDisplayName(formats[0])} dependenc${dependencies.length === 1 ? "y" : "ies"} through ${repositoryLabel}?`; + } + + return `Pull ${dependencies.length} dependencies through ${repositoryLabel}?`; +} + +function buildPullableSummary(dependencies, forceSummary) { + const groups = groupCountsByFormat(dependencies); + if (groups.length === 0) { + return ""; + } + + if (!forceSummary && groups.length === 1) { + return ""; + } + + return `${groups.map(({ count, format }) => `${count} ${formatDisplayName(format)}`).join(" + ")} will be pulled.`; +} + +function buildSkippedSummary(skippedDependencies) { + const groups = groupCountsByFormat(skippedDependencies.map((entry) => ({ + format: entry.format, + }))); + + if (groups.length === 0) { + return ""; + } + + const reason = skippedDependencies.every((entry) => entry.reason === PULL_SKIP_REASON.NO_ACTIVE_UPSTREAM) + ? "no matching upstream is configured on this repository" + : "pull-through is not available for these formats"; + + return `${groups.map(({ count, format }) => `${count} ${formatDisplayName(format)}`).join(" + ")} will be skipped (${reason}).`; +} + +function groupCountsByFormat(dependencies) { + const counts = new Map(); + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + const format = canonicalFormat( + dependency && (dependency.format || dependency.ecosystem || formatForDependency(dependency)) + ); + if (!format) { + continue; + } + counts.set(format, (counts.get(format) || 0) + 1); + } + + return [...counts.entries()] + .map(([format, count]) => ({ format, count })) + .sort((left, right) => { + if (right.count !== left.count) { + return right.count - left.count; + } + return formatDisplayName(left.format).localeCompare(formatDisplayName(right.format), undefined, { + sensitivity: "base", + }); + }); +} + +function formatDisplayName(format) { + const normalized = String(canonicalFormat(format) || format || "").trim().toLowerCase(); + switch (normalized) { + case "npm": + return "npm"; + case "python": + return "Python"; + case "go": + return "Go"; + case "nuget": + return "NuGet"; + default: + return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : "Unknown"; + } +} + +function formatListLabel(formats) { + return [...new Set( + (Array.isArray(formats) ? formats : []) + .map((format) => formatDisplayName(format)) + .filter(Boolean) + )].join(", "); +} + +function normalizeSingleDependency(dependency) { + if (!dependency || typeof dependency !== "object") { + return null; + } + + const format = canonicalFormat(formatForDependency(dependency) || dependency.format || dependency.ecosystem); + const name = String(dependency.name || "").trim(); + if (!name || !format) { + return null; + } + + return { + ...dependency, + name, + version: dependency.version || dependency.declaredVersion || "", + format, + ecosystem: dependency.ecosystem || format, + }; +} + +function buildDependencyLabel(dependency) { + const name = String(dependency && dependency.name || "").trim() || "dependency"; + const version = String(dependency && dependency.version || "").trim(); + return version ? `${name}@${version}` : name; +} + +function buildSingleDependencyRepositoryDetail(match, format) { + const upstreams = match && match.activeUpstreamsByFormat instanceof Map + ? match.activeUpstreamsByFormat.get(format) + : []; + const activeUpstream = Array.isArray(upstreams) ? upstreams[0] : null; + const configuredName = String(activeUpstream && activeUpstream.name || "").trim(); + const sourceLabel = configuredName || defaultUpstreamSourceLabel(format); + if (!sourceLabel) { + return `${formatDisplayName(format)} upstream configured`; + } + return `${formatDisplayName(format)} upstream (${sourceLabel})`; +} + +function defaultUpstreamSourceLabel(format) { + switch (canonicalFormat(format)) { + case "cargo": + return "crates.io"; + case "composer": + return "Packagist"; + case "conda": + return "Conda"; + case "dart": + return "pub.dev"; + case "docker": + return "Docker"; + case "go": + return "Go"; + case "helm": + return "Helm"; + case "hex": + return "Hex"; + case "maven": + return "Maven"; + case "npm": + return "npm"; + case "nuget": + return "NuGet"; + case "python": + return "PyPI"; + case "ruby": + return "RubyGems"; + case "swift": + return "Swift"; + default: + return null; + } +} + +function sortRepositoryMatches(matches, repositoryHint) { + const hint = String(repositoryHint || "").trim().toLowerCase(); + if (!hint) { + return matches; + } + + return matches.slice().sort((left, right) => { + const leftSlug = String(left.repo.slug || "").toLowerCase(); + const rightSlug = String(right.repo.slug || "").toLowerCase(); + const leftPriority = leftSlug === hint ? 0 : 1; + const rightPriority = rightSlug === hint ? 0 : 1; + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority; + } + return leftSlug.localeCompare(rightSlug, undefined, { sensitivity: "base" }); + }); +} + +async function runPromisePool(items, concurrency, worker) { + const workers = []; + let index = 0; + const size = Math.max(1, Math.min(concurrency, items.length || 1)); + + for (let workerIndex = 0; workerIndex < size; workerIndex += 1) { + workers.push((async () => { + while (index < items.length) { + const item = items[index]; + index += 1; + if (item === undefined) { + break; + } + await worker(item); + } + })()); + } + + await Promise.all(workers); +} + +module.exports = { + PULL_SKIP_REASON, + PULL_STATUS, + UpstreamPullService, + buildPullSummaryMessage, +};