diff --git a/CHANGELOG.md b/CHANGELOG.md index 36fd7e5..37e0658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the ALCops extension will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.2] - 2026-04-23 + +### Changed +- Switch NuGet version queries from V3 Flat Container to V3 Registration API (`registration5-gz-semver2`), which provides listing status per version and excludes unlisted packages from update suggestions +- Switch package downloads from V2 API (`www.nuget.org/api/v2/package/`) to V3 Flat Container (`api.nuget.org/v3-flatcontainer/`), fixing User-Agent not appearing in NuGet.org download statistics +- Handle paginated NuGet Registration API responses for packages with 128+ versions (external pages fetched in parallel) + ## [1.3.1] - 2026-04-22 ### Added diff --git a/src/downloader.ts b/src/downloader.ts index 722e3e7..eef1775 100644 --- a/src/downloader.ts +++ b/src/downloader.ts @@ -3,6 +3,7 @@ import * as https from 'https'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import * as zlib from 'zlib'; import { unzipSync } from 'fflate'; import * as vscode from 'vscode'; import { compare, prerelease, valid } from 'semver'; @@ -90,13 +91,13 @@ export function verifyAnalyzerInstallation(targetPath: string): VerificationResu */ export async function queryLatestVersion(channel: 'stable' | 'beta' | 'alpha'): Promise { try { - const indexUrl = `https://api.nuget.org/v3-flatcontainer/${PACKAGE_NAME.toLowerCase()}/index.json`; - const versions = await queryNuGetIndex(indexUrl); + const registrationVersions = await queryNuGetRegistration(PACKAGE_NAME); - const filtered = versions - .filter(v => valid(v) !== null) + const filtered = registrationVersions + .filter(v => v.listed) + .filter(v => valid(v.version) !== null) .filter(v => { - const pre = prerelease(v); + const pre = prerelease(v.version); switch (channel) { case 'stable': return pre === null; case 'beta': return pre === null || !pre.includes('alpha'); @@ -109,7 +110,7 @@ export async function queryLatestVersion(channel: 'stable' | 'beta' | 'alpha'): return null; } - return filtered.sort((a, b) => compare(a, b)).at(-1)!; + return filtered.sort((a, b) => compare(a.version, b.version)).at(-1)!.version; } catch (error) { console.error('Error querying NuGet for latest version:', error); return null; @@ -130,26 +131,108 @@ function httpsGetWithRedirects( }).on('error', onError); } -function queryNuGetIndex(indexUrl: string): Promise { +export interface RegistrationVersion { + version: string; + listed: boolean; + packageContent: string; +} + +interface RegistrationCatalogEntry { + version: string; + listed?: boolean; +} + +interface RegistrationLeaf { + catalogEntry: RegistrationCatalogEntry; + packageContent: string; +} + +interface RegistrationPage { + '@id': string; + items?: RegistrationLeaf[]; +} + +export interface RegistrationIndex { + items: RegistrationPage[]; +} + +/** + * Fetches a URL and returns parsed JSON, handling gzip decompression when the + * server responds with `Content-Encoding: gzip`. Used for NuGet V3 Registration + * API requests which are always gzip-compressed in the `-gz-semver2` hive. + */ +function fetchJsonWithGzip(url: string): Promise { return new Promise((resolve, reject) => { - httpsGetWithRedirects(indexUrl, (response) => { + httpsGetWithRedirects(url, (response) => { if (response.statusCode !== 200) { - reject(new Error(`Failed to query NuGet index. Status: ${response.statusCode}`)); + reject(new Error(`HTTP ${response.statusCode} for ${url}`)); return; } - let data = ''; - response.on('data', (chunk) => { data += chunk; }); + + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => { chunks.push(chunk); }); response.on('end', () => { try { - resolve(JSON.parse(data).versions || []); + const buffer = Buffer.concat(chunks); + const isGzip = response.headers['content-encoding'] === 'gzip'; + const text = isGzip ? zlib.gunzipSync(buffer).toString('utf-8') : buffer.toString('utf-8'); + resolve(JSON.parse(text) as T); } catch (error) { - reject(new Error(`Failed to parse NuGet index response: ${formatError(error)}`)); + reject(new Error(`Failed to parse response from ${url}: ${formatError(error)}`)); } }); }, reject); }); } +/** + * Parses a NuGet V3 Registration index response into a flat list of versions. + * Handles the nested page/leaf/catalogEntry structure. All pages must have their + * items inlined (external pages should be resolved before calling this function). + */ +export function parseRegistrationIndex(json: RegistrationIndex): RegistrationVersion[] { + const versions: RegistrationVersion[] = []; + for (const page of json.items) { + if (page.items) { + for (const leaf of page.items) { + versions.push({ + version: leaf.catalogEntry.version, + listed: leaf.catalogEntry.listed ?? true, + packageContent: leaf.packageContent, + }); + } + } + } + return versions; +} + +/** + * Queries the NuGet V3 Registration API for package versions with metadata. + * + * Uses the `registration5-gz-semver2` hive which includes SemVer 2.0.0 packages + * and provides listing status per version. The response is gzip-compressed. + * + * For packages with <128 versions, all page data is inlined in the index response. + * For packages with 128+ versions, pages are external references that must be + * fetched separately. External pages are fetched in parallel. + */ +export async function queryNuGetRegistration(packageId: string): Promise { + const registrationUrl = `https://api.nuget.org/v3/registration5-gz-semver2/${packageId.toLowerCase()}/index.json`; + const index = await fetchJsonWithGzip(registrationUrl); + + const externalPages = index.items.filter(page => !page.items); + if (externalPages.length > 0) { + const fetched = await Promise.all( + externalPages.map(page => fetchJsonWithGzip(page['@id'])) + ); + for (let i = 0; i < externalPages.length; i++) { + externalPages[i].items = fetched[i].items; + } + } + + return parseRegistrationIndex(index); +} + export async function downloadALCopsAnalyzers(version: string): Promise { // Use mutex to ensure only one installation at a time - prevents race conditions return installationMutex.withLock(() => downloadALCopsAnalyzersInternal(version)); @@ -159,7 +242,9 @@ export async function downloadALCopsAnalyzers(version: string): Promise { * Internal implementation (wrapped by mutex) */ async function downloadALCopsAnalyzersInternal(version: string): Promise { - const downloadUrl = `https://www.nuget.org/api/v2/package/${PACKAGE_NAME}/${version}`; + const lowerPackageName = PACKAGE_NAME.toLowerCase(); + const lowerVersion = version.toLowerCase(); + const downloadUrl = `https://api.nuget.org/v3-flatcontainer/${lowerPackageName}/${lowerVersion}/${lowerPackageName}.${lowerVersion}.nupkg`; let tempDir: string | null = null; try { diff --git a/tests/downloader.test.ts b/tests/downloader.test.ts index e89e146..cd034d7 100644 --- a/tests/downloader.test.ts +++ b/tests/downloader.test.ts @@ -1,11 +1,23 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import * as os from 'node:os'; +import * as zlib from 'node:zlib'; +import { EventEmitter } from 'node:events'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -vi.mock('vscode', () => ({})); +vi.mock('vscode', () => ({ + extensions: { + getExtension: () => ({ packageJSON: { version: '0.0.0-test' } }), + }, +})); -import { findMatchingLibFolder } from '../src/downloader.js'; +const mockHttpsGet = vi.fn(); +vi.mock('https', () => ({ + get: (...args: unknown[]) => mockHttpsGet(...args), +})); + +import { findMatchingLibFolder, parseRegistrationIndex, queryNuGetRegistration } from '../src/downloader.js'; +import type { RegistrationIndex } from '../src/downloader.js'; describe('findMatchingLibFolder', () => { let tempDir: string; @@ -75,3 +87,318 @@ describe('findMatchingLibFolder', () => { expect(findMatchingLibFolder(tempDir, 'net8.0')).toBe('netstandard2.1'); }); }); + +function makeRegistrationIndex( + leaves: Array<{ version: string; listed?: boolean; packageContent?: string }> +): RegistrationIndex { + return { + items: [{ + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/index.json#page/0', + items: leaves.map(l => ({ + catalogEntry: { + version: l.version, + listed: l.listed, + }, + packageContent: l.packageContent ?? + `https://api.nuget.org/v3-flatcontainer/test/${l.version.toLowerCase()}/test.${l.version.toLowerCase()}.nupkg`, + })), + }], + }; +} + +describe('parseRegistrationIndex', () => { + it('extracts versions from inlined page items', () => { + const index = makeRegistrationIndex([ + { version: '1.0.0', listed: true }, + { version: '2.0.0', listed: true }, + ]); + const result = parseRegistrationIndex(index); + expect(result).toHaveLength(2); + expect(result[0].version).toBe('1.0.0'); + expect(result[1].version).toBe('2.0.0'); + }); + + it('preserves listing status', () => { + const index = makeRegistrationIndex([ + { version: '1.0.0', listed: true }, + { version: '1.1.0-alpha.1', listed: false }, + ]); + const result = parseRegistrationIndex(index); + expect(result[0].listed).toBe(true); + expect(result[1].listed).toBe(false); + }); + + it('defaults listed to true when undefined', () => { + const index = makeRegistrationIndex([ + { version: '1.0.0', listed: undefined }, + ]); + const result = parseRegistrationIndex(index); + expect(result[0].listed).toBe(true); + }); + + it('extracts packageContent URLs', () => { + const url = 'https://api.nuget.org/v3-flatcontainer/test/1.0.0/test.1.0.0.nupkg'; + const index = makeRegistrationIndex([ + { version: '1.0.0', listed: true, packageContent: url }, + ]); + const result = parseRegistrationIndex(index); + expect(result[0].packageContent).toBe(url); + }); + + it('skips pages without inlined items', () => { + const index: RegistrationIndex = { + items: [ + { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/0', + }, + { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/1', + items: [{ + catalogEntry: { version: '2.0.0', listed: true }, + packageContent: 'https://example.com/test.2.0.0.nupkg', + }], + }, + ], + }; + const result = parseRegistrationIndex(index); + expect(result).toHaveLength(1); + expect(result[0].version).toBe('2.0.0'); + }); + + it('handles multiple pages with inlined items', () => { + const index: RegistrationIndex = { + items: [ + { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/0', + items: [ + { catalogEntry: { version: '1.0.0', listed: true }, packageContent: 'https://example.com/1' }, + ], + }, + { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/1', + items: [ + { catalogEntry: { version: '2.0.0', listed: false }, packageContent: 'https://example.com/2' }, + ], + }, + ], + }; + const result = parseRegistrationIndex(index); + expect(result).toHaveLength(2); + expect(result[0].version).toBe('1.0.0'); + expect(result[1].version).toBe('2.0.0'); + }); + + it('returns empty array for empty index', () => { + const index: RegistrationIndex = { items: [] }; + expect(parseRegistrationIndex(index)).toEqual([]); + }); +}); + +/** + * Creates a mock HTTP response emitter that emits the given JSON body, + * optionally gzip-compressed, as a stream. + */ +function createMockResponse( + body: unknown, + options: { gzip?: boolean; statusCode?: number } = {} +): EventEmitter & { statusCode: number; headers: Record } { + const response = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + }; + response.statusCode = options.statusCode ?? 200; + const json = JSON.stringify(body); + const useGzip = options.gzip ?? true; + response.headers = useGzip ? { 'content-encoding': 'gzip' } : {}; + + // Emit data on next tick so the caller can attach listeners first + process.nextTick(() => { + const buf = Buffer.from(json, 'utf-8'); + response.emit('data', useGzip ? zlib.gzipSync(buf) : buf); + response.emit('end'); + }); + + return response; +} + +describe('queryNuGetRegistration', () => { + afterEach(() => { + mockHttpsGet.mockReset(); + }); + + it('returns versions from a fully-inlined index', async () => { + const indexBody: RegistrationIndex = { + items: [{ + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/index.json#page/0', + items: [ + { + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://api.nuget.org/v3-flatcontainer/test/1.0.0/test.1.0.0.nupkg', + }, + { + catalogEntry: { version: '2.0.0', listed: false }, + packageContent: 'https://api.nuget.org/v3-flatcontainer/test/2.0.0/test.2.0.0.nupkg', + }, + ], + }], + }; + + mockHttpsGet.mockImplementation((_url: unknown, _opts: unknown, cb: unknown) => { + (cb as (r: unknown) => void)(createMockResponse(indexBody)); + const req = new EventEmitter(); + return Object.assign(req, { on: vi.fn().mockReturnThis() }); + }); + + const result = await queryNuGetRegistration('Test'); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + version: '1.0.0', listed: true, + packageContent: 'https://api.nuget.org/v3-flatcontainer/test/1.0.0/test.1.0.0.nupkg', + }); + expect(result[1].listed).toBe(false); + }); + + it('fetches external pages and merges results', async () => { + const indexBody = { + items: [ + { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/1.0.0/1.9.9.json', + count: 1, + lower: '1.0.0', + upper: '1.9.9', + // No items — external page + }, + { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/2.0.0/2.9.9.json', + count: 1, + lower: '2.0.0', + upper: '2.9.9', + // No items — external page + }, + ], + }; + + const page0Body = { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/1.0.0/1.9.9.json', + items: [{ + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://api.nuget.org/v3-flatcontainer/test/1.0.0/test.1.0.0.nupkg', + }], + }; + + const page1Body = { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/2.0.0/2.9.9.json', + items: [{ + catalogEntry: { version: '2.0.0', listed: true }, + packageContent: 'https://api.nuget.org/v3-flatcontainer/test/2.0.0/test.2.0.0.nupkg', + }], + }; + + const responses: Record = { + 'https://api.nuget.org/v3/registration5-gz-semver2/test/index.json': indexBody, + 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/1.0.0/1.9.9.json': page0Body, + 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/2.0.0/2.9.9.json': page1Body, + }; + + mockHttpsGet.mockImplementation((url: unknown, _opts: unknown, cb: unknown) => { + const body = responses[url as string]; + if (!body) { + throw new Error(`Unexpected URL: ${url}`); + } + (cb as (r: unknown) => void)(createMockResponse(body)); + const req = new EventEmitter(); + return Object.assign(req, { on: vi.fn().mockReturnThis() }); + }); + + const result = await queryNuGetRegistration('Test'); + expect(result).toHaveLength(2); + expect(result[0].version).toBe('1.0.0'); + expect(result[1].version).toBe('2.0.0'); + }); + + it('handles mix of inlined and external pages', async () => { + const indexBody = { + items: [ + { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/index.json#page/0', + items: [{ + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://api.nuget.org/v3-flatcontainer/test/1.0.0/test.1.0.0.nupkg', + }], + }, + { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/2.0.0/2.9.9.json', + count: 1, + lower: '2.0.0', + upper: '2.9.9', + }, + ], + }; + + const externalPageBody = { + items: [{ + catalogEntry: { version: '2.0.0', listed: false }, + packageContent: 'https://api.nuget.org/v3-flatcontainer/test/2.0.0/test.2.0.0.nupkg', + }], + }; + + const responses: Record = { + 'https://api.nuget.org/v3/registration5-gz-semver2/test/index.json': indexBody, + 'https://api.nuget.org/v3/registration5-gz-semver2/test/page/2.0.0/2.9.9.json': externalPageBody, + }; + + mockHttpsGet.mockImplementation((url: unknown, _opts: unknown, cb: unknown) => { + const body = responses[url as string]; + if (!body) { + throw new Error(`Unexpected URL: ${url}`); + } + (cb as (r: unknown) => void)(createMockResponse(body)); + const req = new EventEmitter(); + return Object.assign(req, { on: vi.fn().mockReturnThis() }); + }); + + const result = await queryNuGetRegistration('Test'); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + version: '1.0.0', listed: true, + packageContent: 'https://api.nuget.org/v3-flatcontainer/test/1.0.0/test.1.0.0.nupkg', + }); + expect(result[1]).toEqual({ + version: '2.0.0', listed: false, + packageContent: 'https://api.nuget.org/v3-flatcontainer/test/2.0.0/test.2.0.0.nupkg', + }); + }); + + it('lowercases the package ID in the registration URL', async () => { + const indexBody: RegistrationIndex = { + items: [{ + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/mypackage/index.json#page/0', + items: [{ + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://api.nuget.org/v3-flatcontainer/mypackage/1.0.0/mypackage.1.0.0.nupkg', + }], + }], + }; + + let capturedUrl = ''; + mockHttpsGet.mockImplementation((url: unknown, _opts: unknown, cb: unknown) => { + capturedUrl = url as string; + (cb as (r: unknown) => void)(createMockResponse(indexBody)); + const req = new EventEmitter(); + return Object.assign(req, { on: vi.fn().mockReturnThis() }); + }); + + await queryNuGetRegistration('MyPackage'); + expect(capturedUrl).toBe('https://api.nuget.org/v3/registration5-gz-semver2/mypackage/index.json'); + }); + + it('rejects when index fetch returns non-200 status', async () => { + mockHttpsGet.mockImplementation((_url: unknown, _opts: unknown, cb: unknown) => { + (cb as (r: unknown) => void)(createMockResponse({}, { statusCode: 404, gzip: false })); + const req = new EventEmitter(); + return Object.assign(req, { on: vi.fn().mockReturnThis() }); + }); + + await expect(queryNuGetRegistration('nonexistent')).rejects.toThrow('HTTP 404'); + }); +});