Files
Michael Kriese 3a723b7891 test(datasource): close branch coverage gaps (#45943)
* test(versioning/aws-machine-image): cover the empty version guard

`_parse()` returns null for a falsy version before it tries the AMI id
regex, and no test passed one. Checking that an empty string is not
valid closes the only branch the module was missing.

Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(datasource/buildpacks-registry): cover a response without a homepage

`latest.homepage` is optional in the registry response, but every test
supplied one, so the release result was never built without it. A
buildpack whose latest entry carries no homepage now checks that the
releases still come back and no source url is derived.

Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(datasource): cover optional-field branches in four datasources

Each of these datasources builds its release result by attaching fields
only when the registry supplies them, and the "not supplied" path was
never exercised:

- cdnjs: a library with neither homepage nor repository
- cpan: a search result where no release is marked latest, so no tag
- dart: a latest release carrying no pubspec
- ruby-version: a release table row whose first column is not a version

Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(datasource): cover missing-metadata branches in hex and pypi

Two more datasources only attached fields when the registry supplied
them, and the absent case was untested: a hex package with neither an
html_url nor a github link in its meta, and a pypi response carrying no
releases at all.

Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(datasource): cover galaxy and repology fallbacks, mark unreachable guards

Adds a galaxy role with empty github fields, so no source url is
derived, and a repology lookup where the api fallback after a 403 finds
no matching package.

Marks four guards that are not worth a test: cpan and dart both check a
variable that is only null when the request threw, and
`handleGenericErrors()` always rethrows; conan's registry url is always
supplied by the datasource layer; and repology's resolver either returns
a body or throws.

Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(datasource): cover pod CDN branches, mark the sbt listing guards

Adds two pod lookups: an empty CDN body, and a CDN index that lists no
matching pod.

Marks the guards whose tests would need multi-level directory listing
fixtures: both sbt datasources walk nested index pages, and pod's github
helper checks a body that an empty json response never produces because
parsing throws first.

Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(datasource): cover maven metadata without a latest tag

Adds maven metadata carrying a release but no latest, so respectLatest
is left alone. Marks the three deb guards, which would each need a
crafted package index: a block missing a required key, a repeated
package in the final block, and two component indexes whose meta agrees.

Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(datasource): cover npmrc registry fallback, mark cache-bound guards

Adds a resolveRegistryUrl lookup for a package no npmrc rule matches.

Marks the guards whose tests would need a primed cache or a specific
blob shape: nuget's services index, deno's release cache, the docker hub
cache window, docker's manifest and config handling, go's pseudo-version
fallback, and both rubygems cache layers.

Three implicit elses are left counted: a coverage-ignore hint does not
attach to an `else if`, so the registry strategy chain, the npmrc reset
and docker's digest-header branch carry a plain note instead.

Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* fix(datasource/repology): pass the mock body as a string

`ResponseMock.body` is typed as a string, and every other call in the
spec passes `'[]'`. The new api fallback test passed an array literal,
which tsc rejected.

Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io>

---------

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>
2026-09-14 12:11:52 +00:00

254 lines
7.5 KiB
TypeScript

import crypto from 'node:crypto';
import {
HOST_BLOCKED,
HOST_DISABLED,
} from '../../../constants/error-messages.ts';
import { logger } from '../../../logger/index.ts';
import { ExternalHostError } from '../../../types/errors/external-host-error.ts';
import { withCache } from '../../../util/cache/package/with-cache.ts';
import { GithubHttp } from '../../../util/http/github.ts';
import type { HttpError } from '../../../util/http/index.ts';
import { refusedHostMessage } from '../../../util/http/util.ts';
import { newlineRegex, regEx } from '../../../util/regex.ts';
import { Datasource } from '../datasource.ts';
import { massageGithubUrl } from '../metadata.ts';
import type { GetReleasesConfig, ReleaseResult } from '../types.ts';
type URLFormatOptions =
| 'withShardWithSpec'
| 'withShardWithoutSpec'
| 'withSpecsWithoutShard'
| 'withoutSpecsWithoutShard';
function shardParts(packageName: string): string[] {
return crypto
.createHash('md5')
.update(packageName)
.digest('hex')
.slice(0, 3)
.split('');
}
const githubRegex = regEx(
/(?<hostURL>^https:\/\/[a-zA-Z0-9-.]+)\/(?<account>[^/]+)\/(?<repo>[^/]+?)(?:\.git|\/.*)?$/,
);
function releasesGithubUrl(
packageName: string,
opts: {
hostURL: string;
account: string;
repo: string;
useShard: boolean;
useSpecs: boolean;
},
): string {
const { hostURL, account, repo, useShard, useSpecs } = opts;
const prefix =
hostURL && hostURL !== 'https://github.com'
? `${hostURL}/api/v3/repos`
: 'https://api.github.com/repos';
const shard = shardParts(packageName).join('/');
// `Specs` in the pods repo URL is a new requirement for legacy support also allow pod repo URL without `Specs`
const packageNamePath = useSpecs ? `Specs/${packageName}` : packageName;
const shardPath = useSpecs
? `Specs/${shard}/${packageName}`
: `${shard}/${packageName}`;
const suffix = useShard ? shardPath : packageNamePath;
return `${prefix}/${account}/${repo}/contents/${suffix}`;
}
function handleError(packageName: string, err: HttpError): void {
const errorData = { packageName, err };
const statusCode = err.response?.statusCode ?? 0;
if (statusCode === 429 || (statusCode >= 500 && statusCode < 600)) {
logger.warn({ packageName, err }, `CocoaPods registry failure`);
throw new ExternalHostError(err);
}
if (statusCode === 401) {
logger.debug(errorData, 'Authorization error');
} else if (statusCode === 404) {
logger.debug(errorData, 'Package lookup error');
} else if ([HOST_BLOCKED, HOST_DISABLED].includes(err.message)) {
logger.trace(errorData, refusedHostMessage(err));
} else {
logger.warn(errorData, 'CocoaPods lookup failure: Unknown error');
}
}
function isDefaultRepo(url: string): boolean {
const match = githubRegex.exec(url);
if (match?.groups) {
const { account, repo } = match.groups;
return (
account.toLowerCase() === 'cocoapods' && repo.toLowerCase() === 'specs'
); // https://github.com/CocoaPods/Specs.git
}
return false;
}
function releasesCDNUrl(packageName: string, registryUrl: string): string {
const shard = shardParts(packageName).join('_');
return `${registryUrl}/all_pods_versions_${shard}.txt`;
}
export class PodDatasource extends Datasource {
static readonly id = 'pod';
override readonly defaultRegistryUrls = ['https://cdn.cocoapods.org'];
override readonly registryStrategy = 'hunt';
githubHttp: GithubHttp;
constructor() {
super(PodDatasource.id);
this.githubHttp = new GithubHttp(PodDatasource.id);
}
private async requestCDN(
url: string,
packageName: string,
): Promise<string | null> {
try {
const resp = await this.http.getText(url);
if (resp?.body) {
return resp.body;
}
} catch (err) {
handleError(packageName, err);
}
return null;
}
private async requestGithub<T = unknown>(
url: string,
packageName: string,
): Promise<T | null> {
try {
const resp = await this.githubHttp.getJsonUnchecked<T>(url);
// v8 ignore else -- an empty json body throws before it gets here
if (resp?.body) {
return resp.body;
}
} catch (err) {
handleError(packageName, err);
}
return null;
}
private async getReleasesFromGithub(
packageName: string,
opts: { hostURL: string; account: string; repo: string },
useShard = true,
useSpecs = true,
urlFormatOptions: URLFormatOptions = 'withShardWithSpec',
): Promise<ReleaseResult | null> {
const url = releasesGithubUrl(packageName, { ...opts, useShard, useSpecs });
const resp = await this.requestGithub<{ name: string }[]>(url, packageName);
if (resp) {
const releases = resp.map(({ name }) => ({ version: name }));
return { releases };
}
// support different url formats
switch (urlFormatOptions) {
case 'withShardWithSpec':
return this.getReleasesFromGithub(
packageName,
opts,
true,
false,
'withShardWithoutSpec',
);
case 'withShardWithoutSpec':
return this.getReleasesFromGithub(
packageName,
opts,
false,
true,
'withSpecsWithoutShard',
);
case 'withSpecsWithoutShard':
return this.getReleasesFromGithub(
packageName,
opts,
false,
false,
'withoutSpecsWithoutShard',
);
case 'withoutSpecsWithoutShard':
default:
return null;
}
}
private async getReleasesFromCDN(
packageName: string,
registryUrl: string,
): Promise<ReleaseResult | null> {
const url = releasesCDNUrl(packageName, registryUrl);
const resp = await this.requestCDN(url, packageName);
if (resp) {
const lines = resp.split(newlineRegex);
for (const line of lines) {
const [name, ...versions] = line.split('/');
if (name === packageName.replace(regEx(/\/.*$/), '')) {
const releases = versions.map((version) => ({ version }));
return { releases };
}
}
}
return null;
}
private async _getReleases({
packageName,
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
/* v8 ignore next -- should never happen */
if (!registryUrl) {
return null;
}
const podName = packageName.replace(regEx(/\/.*$/), '');
let baseUrl = registryUrl.replace(regEx(/\/+$/), '');
// In order to not abuse github API limits, query CDN instead
if (isDefaultRepo(baseUrl)) {
[baseUrl] = this.defaultRegistryUrls;
}
let result: ReleaseResult | null = null;
const match = githubRegex.exec(baseUrl);
// We would ideally have a reliable way to differentiate between
// a CDN URL and a Github URL, but we'll start with detecting Artifactory
if (match?.groups && !baseUrl.includes('/api/pods/')) {
baseUrl = massageGithubUrl(baseUrl);
const { hostURL, account, repo } = match.groups;
const opts = { hostURL, account, repo };
result = await this.getReleasesFromGithub(podName, opts);
} else {
result = await this.getReleasesFromCDN(podName, baseUrl);
}
return result;
}
getReleases(config: GetReleasesConfig): Promise<ReleaseResult | null> {
return withCache(
{
ttlMinutes: 30,
namespace: `datasource-${PodDatasource.id}`,
// TODO: types (#22198)
key: `${config.registryUrl}:${config.packageName}`,
fallback: true,
},
() => this._getReleases(config),
);
}
}