Files
Punsoong Khut 795d083264 feat(datasource): add APK datasource support (#40250)
* feat(datasource): add APK datasource support

* remove missing manager link for now

* lint docs

* update tests

* replace binary fixtures

* fix coverage

* Update lib/modules/datasource/apk/readme.md

Co-authored-by: Jamie Tanna <github@jamietanna.co.uk>

* Apply suggestion from @viceice

Co-authored-by: Michael Kriese <michael.kriese@gmx.de>

* use tar instead of tar-stream

* use codeBlock

* add ignore back

* make method private

* use an async extract instead of list

* add usage example

* stream apkindex download and parse line-by-line

* refactor(datasource): address review comments on APK datasource

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: sync pnpm-lock.yaml after merge

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: remove ignored lines

As they seem to be covered, anyway.

* fixup! Merge remote-tracking branch 'origin/HEAD' into apk-datasource

* fixup! fixup! Merge remote-tracking branch 'origin/HEAD' into apk-datasource

* lint: use `node:` prefix

* Update lib/modules/datasource/apk/index.ts

Co-authored-by: Michael Kriese <michael.kriese@gmx.de>

* refactor(datasource/apk): use asTimestamp millis instead of new Date

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(datasource/apk): rename uncached method to _getPackages

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(datasource/apk): encode registry URL parts as query parameters

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(datasource/apk): reject invalid registry URLs, skip caching empty indexes

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(datasource/apk): throw on unparseable registry URL and drop unset sourceUrl support

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(datasource/apk): show how registry URL parameters build the index URLs

Co-authored-by: Cursor <cursoragent@cursor.com>

* Apply suggestions from code review

Co-authored-by: RahulGautamSingh <rahultesnik@gmail.com>

* fix(datasource/apk): keep credentials and port in component URLs

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(datasource/apk): extract APKINDEX by basename and key packages by name

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(datasource/apk): report a version served by two components only once

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Jamie Tanna <github@jamietanna.co.uk>
Co-authored-by: Michael Kriese <michael.kriese@gmx.de>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Jamie Tanna <jamie.tanna@mend.io>
Co-authored-by: RahulGautamSingh <rahultesnik@gmail.com>
2026-09-04 12:25:21 +00:00

133 lines
4.4 KiB
TypeScript

import { isNotNullOrUndefined } from '../../../util/array.ts';
import { regEx } from '../../../util/regex.ts';
import { joinUrlParts, parseUrl } from '../../../util/url.ts';
const REQUIRED_PARAMS = ['arch'];
const OPTIONAL_PARAMS = ['branch', 'components'];
const KNOWN_PARAMS = [...REQUIRED_PARAMS, ...OPTIONAL_PARAMS];
// Parameter values become path segments verbatim, so anything which could escape
// or reshape the resulting URL is rejected
const validSegment = regEx(/^[A-Za-z0-9][A-Za-z0-9._+-]*$/);
/**
* Constructs the `APKINDEX` directory URLs from the given registry URL.
*
* @param registryUrl - The base URL of the registry, with the path segments encoded as query parameters.
* @returns One URL per component.
* @throws Will throw an error if the registry URL cannot be parsed, or if the query
* parameters are missing, unknown or malformed.
*
* @example
* // Returns ['https://dl-cdn.alpinelinux.org/alpine/v3.19/main/x86_64',
* // 'https://dl-cdn.alpinelinux.org/alpine/v3.19/community/x86_64']
* constructComponentUrls('https://dl-cdn.alpinelinux.org/alpine?branch=v3.19&components=main,community&arch=x86_64');
*/
export function constructComponentUrls(registryUrl: string): string[] {
try {
const url = parseUrl(registryUrl);
if (!url) {
throw new Error('Cannot parse URL');
}
validateParams(url);
const arch = validateSegment('arch', url.searchParams.get('arch')!);
const branch = getBranch(url);
const components = getComponents(url);
// The parameters describe the path, so they are stripped before the segments
// are appended. Unknown parameters are rejected above, so nothing else remains.
for (const param of KNOWN_PARAMS) {
url.searchParams.delete(param);
}
url.hash = '';
const baseUrl = url.toString();
return components.map((component) =>
joinUrlParts(
baseUrl,
...[branch, component, arch].filter(isNotNullOrUndefined),
),
);
} catch (error) {
throw new Error(
`Invalid apk repo URL: ${registryUrl} - see documentation: ${error.message}`,
);
}
}
/**
* Validates that the required parameters are present, and that no unknown parameters are given.
*
* Unknown parameters are rejected so that a typo such as `component=main` fails loudly
* instead of being silently treated as a repository without components.
*
* @param url - The URL to validate.
* @throws Will throw an error if a required parameter is missing or an unknown parameter is present.
*/
function validateParams(url: URL): void {
for (const param of REQUIRED_PARAMS) {
if (!url.searchParams.has(param)) {
throw new Error(`Missing required query parameter '${param}'`);
}
}
for (const param of url.searchParams.keys()) {
if (!KNOWN_PARAMS.includes(param)) {
throw new Error(`Unknown query parameter '${param}'`);
}
}
}
/**
* Validates a single parameter value which is used as a path segment.
*
* @param param - The name of the query parameter, used for the error message.
* @param value - The value to validate.
* @returns The validated value.
* @throws Will throw an error if the value is not usable as a path segment.
*/
function validateSegment(param: string, value: string): string {
if (!validSegment.test(value)) {
throw new Error(`Invalid '${param}' query parameter: '${value}'`);
}
return value;
}
/**
* Retrieves the branch from the URL.
*
* Repositories such as Wolfi have no branch in their path, so `null` is returned
* when `branch` is absent.
*
* @param url - The URL to retrieve the branch from.
* @returns The branch, or `null` when there is none.
*/
function getBranch(url: URL): string | null {
const branch = url.searchParams.get('branch');
return branch === null ? null : validateSegment('branch', branch);
}
/**
* Retrieves the components from the URL.
*
* Repositories such as Wolfi serve their index directly below the repository
* root, so a single component-less URL is returned when `components` is absent.
*
* @param url - The URL to retrieve the components from.
* @returns The list of components, or `[undefined]` when there are none.
*/
function getComponents(url: URL): (string | undefined)[] {
const components = url.searchParams
.get('components')
?.split(',')
.map((component) => component.trim())
.filter((component) => component !== '')
.map((component) => validateSegment('components', component));
return components?.length ? components : [undefined];
}