import { createReadStream } from 'node:fs'; import { copyFile, stat } from 'fs-extra'; import type { DirectoryResult } from 'tmp-promise'; import { dir } from 'tmp-promise'; import upath from 'upath'; import { Fixtures } from '~test/fixtures.ts'; import * as httpMock from '~test/http-mock.ts'; import { fs } from '~test/util.ts'; import { GlobalConfig } from '../../../config/global.ts'; import { hashStream, toSha256 } from '../../../util/hash.ts'; import { getPkgReleases } from '../index.ts'; import type { GetPkgReleasesConfig } from '../types.ts'; import { cacheSubDir } from './common.ts'; import { DebDatasource } from './index.ts'; import { getBaseSuiteUrl, getComponentUrl, getPackageUrl, getRegistryUrl, } from './url.ts'; vi.unmock('../../../util/mutex.ts'); const debBaseUrl = 'http://deb.debian.org'; describe('modules/datasource/deb/index', () => { const fixturePackagesArchivePath = Fixtures.getPath(`Packages.gz`); const fixturePackagesArchivePath2 = Fixtures.getPath(`Packages2.gz`); const fixturePackagesPath = Fixtures.getPath(`Packages`); let fixturePackagesArchiveHash: string; let fixturePackagesArchiveHash2: string; let debDatasource: DebDatasource; let cacheDir: DirectoryResult | null; let cfg: GetPkgReleasesConfig; let extractionFolder: string; let extractedPackageFile: string; beforeEach(async () => { debDatasource = new DebDatasource(); cacheDir = await dir({ unsafeCleanup: true }); GlobalConfig.set({ cacheDir: cacheDir.path }); extractionFolder = await fs.ensureCacheDir(cacheSubDir); extractedPackageFile = upath.join( extractionFolder, `${toSha256(getComponentUrl(debBaseUrl, 'stable', 'non-free', 'amd64'))}.txt`, ); cfg = { datasource: 'deb', packageName: 'album', registryUrls: [ getRegistryUrl(debBaseUrl, 'stable', ['non-free'], 'amd64'), ], }; fixturePackagesArchiveHash = await computeFileChecksum( fixturePackagesArchivePath, ); fixturePackagesArchiveHash2 = await computeFileChecksum( fixturePackagesArchivePath2, ); }); afterEach(async () => { await cacheDir?.cleanup(); cacheDir = null; }); describe('getReleases', () => { it('returns a valid version for the package `album` and does not require redownload', async () => { await copyFile(fixturePackagesPath, extractedPackageFile); const stats = await stat(extractedPackageFile); const ts = stats.ctime; httpMock .scope(debBaseUrl, { // ensure the rest call sets the correct request headers reqheaders: { 'if-modified-since': ts.toUTCString(), }, }) .head(getPackageUrl('', 'stable', 'non-free', 'amd64')) .reply(304); const res = await getPkgReleases(cfg); expect(res).toEqual({ homepage: 'http://marginalhacks.com/Hacks/album', registryUrl: 'http://deb.debian.org/debian?suite=stable&components=non-free&binaryArch=amd64', releases: [ { version: '4.15-1', }, ], }); }); describe('parsing of registry url', () => { it('returns null when registry url misses components', async () => { cfg.registryUrls = [ `${debBaseUrl}/debian?suite=stable&binaryArch=amd64`, ]; const res = await getPkgReleases(cfg); expect(res).toBeNull(); }); it('returns null when registry url misses binaryArch', async () => { cfg.registryUrls = [ `${debBaseUrl}/debian?suite=stable&components=non-free`, ]; const res = await getPkgReleases(cfg); expect(res).toBeNull(); }); it('returns null when registry url misses suite or release', async () => { cfg.registryUrls = [ `${debBaseUrl}/debian?components=non-free&binaryArch=amd64`, ]; const res = await getPkgReleases(cfg); expect(res).toBeNull(); }); }); describe('without local version', () => { beforeEach(() => { mockHttpCalls( 'stable', 'non-free', 'amd64', false, fixturePackagesArchivePath, fixturePackagesArchiveHash, ); }); it('returns a valid version for the package `album`', async () => { const res = await getPkgReleases(cfg); expect(res).toEqual({ homepage: 'http://marginalhacks.com/Hacks/album', registryUrl: 'http://deb.debian.org/debian?suite=stable&components=non-free&binaryArch=amd64', releases: [ { version: '4.15-1', }, ], }); }); it('returns a valid version for the package `album` if release is used in the registryUrl', async () => { cfg.registryUrls = [ getRegistryUrl(debBaseUrl, 'stable', ['non-free'], 'amd64'), ]; const res = await getPkgReleases(cfg); expect(res).toEqual({ homepage: 'http://marginalhacks.com/Hacks/album', registryUrl: 'http://deb.debian.org/debian?suite=stable&components=non-free&binaryArch=amd64', releases: [ { version: '4.15-1', }, ], }); }); it('returns null for an unknown package', async () => { cfg.packageName = 'you-will-never-find-me'; const res = await getPkgReleases(cfg); expect(res).toBeNull(); }); describe('with two components', () => { beforeEach(() => { httpMock .scope(debBaseUrl) .get(getPackageUrl('', 'stable', 'non-free-second', 'amd64')) .replyWithFile(200, fixturePackagesArchivePath2); mockFetchInReleaseContent( fixturePackagesArchiveHash2, 'stable', 'non-free-second', 'amd64', ); cfg.registryUrls = [ getRegistryUrl( debBaseUrl, 'stable', ['non-free', 'non-free-second'], 'amd64', ), ]; }); it('returns two releases for `album` which is the same across the components', async () => { const res = await getPkgReleases(cfg); expect(res).toEqual({ homepage: 'http://marginalhacks.com/Hacks/album', registryUrl: 'http://deb.debian.org/debian?suite=stable&components=non-free,non-free-second&binaryArch=amd64', releases: [ { version: '4.14-1', }, { version: '4.15-1', }, ], }); }); it('returns two releases for `album` which has different metadata across the components', async () => { cfg.packageName = 'album'; const res = await getPkgReleases(cfg); expect(res).toEqual({ homepage: 'http://marginalhacks.com/Hacks/album', registryUrl: 'http://deb.debian.org/debian?suite=stable&components=non-free,non-free-second&binaryArch=amd64', releases: [ { version: '4.14-1', }, { version: '4.15-1', }, ], }); }); }); }); describe('without server response', () => { beforeEach(() => { httpMock .scope(debBaseUrl) .get(getPackageUrl('', 'stable', 'non-free', 'amd64')) .reply(404); }); it('returns null for the package', async () => { cfg.packageName = 'you-will-never-find-me'; const res = await getPkgReleases(cfg); expect(res).toBeNull(); }); }); it('supports specifying a custom binary arch', async () => { httpMock .scope(debBaseUrl) .get(getPackageUrl('', 'stable', 'non-free', 'riscv')) .replyWithFile(200, fixturePackagesArchivePath); mockFetchInReleaseContent( fixturePackagesArchiveHash, 'stable', 'non-free', 'riscv', ); cfg.registryUrls = [ getRegistryUrl(debBaseUrl, 'stable', ['non-free'], 'riscv'), ]; const res = await getPkgReleases(cfg); expect(res).toEqual({ homepage: 'http://marginalhacks.com/Hacks/album', registryUrl: 'http://deb.debian.org/debian?suite=stable&components=non-free&binaryArch=riscv', releases: [ { version: '4.15-1', }, ], }); }); it('should not lead to a race condition on parallel lookups', async () => { const packages = [ 'album', 'album-data', 'alien-arena-data', 'amiwm', 'arb', 'arb-common', 'libfaac-dev', 'amoeba-data', ]; for (let i = 0; i < packages.length; i++) { // first call doesn't include a http head call, since the file doesn't exists locally yet // the package index is downloaded every time since the http head call returns 200 mockHttpCalls( 'stable', 'non-free', 'amd64', !!i, fixturePackagesArchivePath, fixturePackagesArchiveHash, ); } const results = await Promise.all( packages.map((packageName) => getPkgReleases({ ...cfg, packageName })), ); for (const result of results) { expect(result?.releases?.length).toBe(1); } }); }); describe('parseExtractedPackageIndex', () => { it('should parse the extracted package', async () => { await copyFile(fixturePackagesPath, extractedPackageFile); const parsedPackages = await debDatasource.parseExtractedPackageIndex( extractedPackageFile, new Date(), ); expect(parsedPackages).toEqual({ album: [ { Homepage: 'http://marginalhacks.com/Hacks/album', Package: 'album', Version: '4.15-1', }, ], 'album-data': [ { Homepage: 'http://marginalhacks.com/Hacks/album', Package: 'album-data', Version: '4.05-7.2', }, { Homepage: 'http://marginalhacks.com/Hacks/album', Package: 'album-data', Version: '4.05-7.3', }, ], 'alien-arena-data': [ { Homepage: 'https://martianbackup.com', Package: 'alien-arena-data', Version: '7.71.3+ds-1', }, ], }); }); }); }); /** * Mocks several http calls for the in parallel lookup test * * - Mocks the http get call for the Package Index file * - Mocks the http get call for the InRelease file * - Mocks the http head call for Package Index file (returns 200) * * @param release - The release name (e.g., 'bullseye'). * @param component - The component name (e.g., 'main'). * @param arch - The architecture (e.g., 'amd64'). * @param checkIfModified - whether it should mock the http head call of the Package Index file * @param packageArchivePath - path to package index * @param packagesArchiveHash - sha256 hash of package */ function mockHttpCalls( release: string, component: string, arch: string, checkIfModified: boolean, packageArchivePath: string, packagesArchiveHash: string, ) { httpMock .scope(debBaseUrl) .get(getPackageUrl('', release, component, arch)) .replyWithFile(200, packageArchivePath); mockFetchInReleaseContent(packagesArchiveHash, release, component, arch); if (checkIfModified) { httpMock .scope(debBaseUrl) .head(getPackageUrl('', release, component, arch)) .reply(200); } } /** * Mocks the response for fetching the InRelease file content. * * This function sets up a mock HTTP response for a specific InRelease file request. The content includes a SHA256 checksum * entry for a package index file. It allows simulating both successful and error responses. * * @param packageIndexHash - The SHA256 checksum hash of the package index file. * @param release - The release name (e.g., 'bullseye'). * @param component - The component name (e.g., 'main'). * @param arch - The architecture (e.g., 'amd64'). * @param error - Optional flag to simulate an error response (default is false). */ export function mockFetchInReleaseContent( packageIndexHash: string, release: string, component: string, arch: string, error = false, ) { const packageIndexPath = `${component}/binary-${arch}/Packages.gz`; const content = `SHA256: 3957f28db16e3f28c7b34ae84f1c929c567de6970f3f1b95dac9b498dd80fe63 738242 contrib/Contents-all ${packageIndexHash} 1234 ${packageIndexPath} `; const mockCall = httpMock .scope(debBaseUrl) .get( `${getBaseSuiteUrl(getComponentUrl('', release, component, arch))}/InRelease`, ); if (error) { mockCall.replyWithError('Unexpected Error'); } else { mockCall.reply(200, content); } } /** * Computes the SHA256 checksum of a specified file. * * @param filePath - path of the file * @returns resolves to the SHA256 checksum */ export function computeFileChecksum(filePath: string): Promise { const stream = createReadStream(filePath); return hashStream(stream, 'sha256'); }