Files
Matthew Nitschke ac22678812 feat(datasource/npm): integrity and tarball extraction (#44315)
* added support for extracting integrity and tarball from npm datasource

* test(datasource/npm): keep integrity and tarball in packument shape assertions

The packument-trimming tests on main pinned a cached shape that dropped
`dist.tarball`, so they broke once the schema started retaining
`integrity` and `tarball`. Update both expectations to the new shape and
cover `integrity` alongside `tarball`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(workers/lookup): expect integrity and tarball on npm updates

`generateUpdate` copies `downloadUrl` and `newDigest` from the release
onto the update, so every npm lookup in these tests now carries the
tarball URL and, where the packument fixture has one, the integrity
hash. Pin and rollback updates are built without those release fields,
so their expectations are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Michael Kriese <michael.kriese@mend.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:09:59 +00:00

217 lines
7.2 KiB
TypeScript

import { isNonEmptyString, isString } from '@sindresorhus/is';
import { z } from 'zod/v4';
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 * as hostRules from '../../../util/host-rules.ts';
import { PackageHttpCacheProvider } from '../../../util/http/cache/package-http-cache-provider.ts';
import type { Http } from '../../../util/http/index.ts';
import type { HttpOptions } from '../../../util/http/types.ts';
import { regEx } from '../../../util/regex.ts';
import { DeepNullish } from '../../../util/schema-utils/index.ts';
import { asTimestamp } from '../../../util/timestamp.ts';
import { joinUrlParts } from '../../../util/url.ts';
import type { Release, ReleaseResult } from '../types.ts';
import { defaultRegistryUrl } from './common.ts';
import { CachedPackument, NpmResponse } from './schema.ts';
const SHORT_REPO_REGEX = regEx(
/^(?:(?<platform>bitbucket|github|gitlab):)?(?<shortRepo>[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/,
);
const platformMapping: Record<string, string> = {
bitbucket: 'https://bitbucket.org/',
github: 'https://github.com/',
gitlab: 'https://gitlab.com/',
};
interface PackageSource {
sourceUrl: string | null;
sourceDirectory: string | null;
}
const PackageSource = z
.union([
z
.string()
.nonempty()
.transform((repository): PackageSource => {
let sourceUrl: string | null = null;
const sourceDirectory = null;
const shortMatch = repository.match(SHORT_REPO_REGEX);
if (shortMatch?.groups) {
const { platform = 'github', shortRepo } = shortMatch.groups;
sourceUrl = platformMapping[platform] + shortRepo;
} else {
sourceUrl = repository;
}
return { sourceUrl, sourceDirectory };
}),
DeepNullish(
z.object({
url: z.string().nonempty().optional(),
directory: z.string().nonempty().optional(),
}),
).transform(({ url, directory }) => {
const res: PackageSource = { sourceUrl: null, sourceDirectory: null };
if (url) {
res.sourceUrl = url;
}
if (directory) {
res.sourceDirectory = directory;
}
return res;
}),
])
.catch({ sourceUrl: null, sourceDirectory: null });
export async function getDependency(
http: Http,
registryUrl: string,
packageName: string,
): Promise<ReleaseResult | null> {
logger.trace(`npm.getDependency(${packageName})`);
const packageUrl = joinUrlParts(registryUrl, packageName.replace('/', '%2F'));
try {
const cacheProvider = new PackageHttpCacheProvider({
namespace: 'datasource-npm:cache-provider',
checkAuthorizationHeader: false, // We don't rely on whether user token is provided or not
checkCacheControlHeader: true,
writeSchema: CachedPackument,
});
const options: HttpOptions = { cacheProvider };
// set abortOnError for registry.npmjs.org if no hostRule with explicit abortOnError exists
if (
registryUrl === defaultRegistryUrl &&
hostRules.find({ url: defaultRegistryUrl })?.abortOnError === undefined
) {
logger.trace(
{ packageName, registry: defaultRegistryUrl },
'setting abortOnError hostRule for well known host',
);
hostRules.add({
matchHost: defaultRegistryUrl,
abortOnError: true,
});
}
const resp = await http.getJson(packageUrl, options, NpmResponse);
const { body: res } = resp;
if (!res.versions || !Object.keys(res.versions).length) {
// Registry returned a 200 OK but with no versions
logger.debug(`No versions returned for npm dependency ${packageName}`);
return null;
}
const latestVersion = res.versions[res['dist-tags']?.latest ?? ''];
res.repository ??= latestVersion?.repository;
res.homepage ??= latestVersion?.homepage;
const { sourceUrl, sourceDirectory } = PackageSource.parse(res.repository);
// Simplify response before caching and returning
const dep: ReleaseResult = {
homepage: res.homepage,
releases: [],
tags: res['dist-tags'],
registryUrl,
};
if (sourceUrl) {
dep.sourceUrl = sourceUrl;
}
if (sourceDirectory) {
dep.sourceDirectory = sourceDirectory;
}
if (latestVersion?.deprecated) {
dep.deprecationMessage = `On registry \`${registryUrl}\`, the "latest" version of dependency \`${packageName}\` has the following deprecation notice:\n\n\`${latestVersion.deprecated}\`\n\nMarking the latest version of an npm package as deprecated results in the entire package being considered deprecated, so contact the package author you think this is a mistake.`;
}
dep.releases = Object.keys(res.versions).map((version) => {
const release: Release = {
version,
gitRef: res.versions?.[version].gitHead,
dependencies: res.versions?.[version].dependencies,
devDependencies: res.versions?.[version].devDependencies,
attestation: isString(res.versions?.[version].dist?.attestations?.url),
};
if (res.versions?.[version].dist?.integrity) {
release.newDigest = res.versions[version].dist.integrity;
}
if (res.versions?.[version].dist?.tarball) {
release.downloadUrl = res.versions[version].dist.tarball;
}
const releaseTimestamp = asTimestamp(res.time?.[version]);
if (releaseTimestamp) {
release.releaseTimestamp = releaseTimestamp;
}
if (res.versions?.[version].deprecated) {
release.isDeprecated = true;
}
const nodeConstraint = res.versions?.[version].engines?.node;
if (isNonEmptyString(nodeConstraint)) {
release.constraints = { node: [nodeConstraint] };
}
const source = PackageSource.parse(res.versions?.[version].repository);
if (source.sourceUrl && source.sourceUrl !== dep.sourceUrl) {
release.sourceUrl = source.sourceUrl;
}
if (
source.sourceDirectory &&
source.sourceDirectory !== dep.sourceDirectory
) {
release.sourceDirectory = source.sourceDirectory;
}
if (dep.deprecationMessage) {
release.isDeprecated = true;
}
return release;
});
const isPublic = resp.headers?.['cache-control']
?.toLocaleLowerCase()
?.split(regEx(/\s*,\s*/))
?.includes('public');
if (!isPublic) {
dep.isPrivate = true;
}
logger.trace({ dep }, 'dep');
return dep;
} catch (err) {
const actualError = err instanceof ExternalHostError ? err.err : err;
const ignoredStatusCodes = [401, 402, 403, 404];
const ignoredResponseCodes = ['ENOTFOUND'];
if (
actualError.message === HOST_BLOCKED ||
actualError.message === HOST_DISABLED ||
ignoredStatusCodes.includes(actualError.statusCode) ||
ignoredResponseCodes.includes(actualError.code)
) {
return null;
}
if (err instanceof ExternalHostError) {
if (actualError.name === 'ParseError' && actualError.body) {
actualError.body = 'err.body deleted by Renovate';
err.err = actualError;
}
throw err;
}
logger.debug({ err, packageName, registryUrl }, 'Unknown npm lookup error');
return null;
}
}