This commit is contained in:
Sergei Zharinov 2025-01-08 21:28:14 +01:00 committed by GitHub
commit f945623931
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 220 additions and 273 deletions

View file

@ -1,4 +1,6 @@
import { HeadObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Readable } from 'node:stream';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { sdkStreamMixin } from '@smithy/util-stream';
import { mockClient } from 'aws-sdk-client-mock';
import { GoogleAuth as _googleAuth } from 'google-auth-library';
import { DateTime } from 'luxon';
@ -622,10 +624,7 @@ describe('modules/datasource/maven/index', () => {
describe('post-fetch release validation', () => {
it('returns null for 404', async () => {
httpMock
.scope(MAVEN_REPO)
.head('/foo/bar/1.2.3/bar-1.2.3.pom')
.reply(404);
httpMock.scope(MAVEN_REPO).get('/foo/bar/1.2.3/bar-1.2.3.pom').reply(404);
const res = await postprocessRelease(
{ datasource, packageName: 'foo:bar', registryUrl: MAVEN_REPO },
@ -635,25 +634,23 @@ describe('modules/datasource/maven/index', () => {
expect(res).toBeNull();
});
it('returns null for unknown error', async () => {
it('returns original value for unknown error', async () => {
httpMock
.scope(MAVEN_REPO)
.head('/foo/bar/1.2.3/bar-1.2.3.pom')
.get('/foo/bar/1.2.3/bar-1.2.3.pom')
.replyWithError('unknown error');
const releaseOrig: Release = { version: '1.2.3' };
const res = await postprocessRelease(
{ datasource, packageName: 'foo:bar', registryUrl: MAVEN_REPO },
{ version: '1.2.3' },
releaseOrig,
);
expect(res).toBeNull();
expect(res).toBe(releaseOrig);
});
it('returns original value for 200 response', async () => {
httpMock
.scope(MAVEN_REPO)
.head('/foo/bar/1.2.3/bar-1.2.3.pom')
.reply(200);
httpMock.scope(MAVEN_REPO).get('/foo/bar/1.2.3/bar-1.2.3.pom').reply(200);
const releaseOrig: Release = { version: '1.2.3' };
const res = await postprocessRelease(
@ -665,10 +662,7 @@ describe('modules/datasource/maven/index', () => {
});
it('returns original value for 200 response with versionOrig', async () => {
httpMock
.scope(MAVEN_REPO)
.head('/foo/bar/1.2.3/bar-1.2.3.pom')
.reply(200);
httpMock.scope(MAVEN_REPO).get('/foo/bar/1.2.3/bar-1.2.3.pom').reply(200);
const releaseOrig: Release = { version: '1.2', versionOrig: '1.2.3' };
const res = await postprocessRelease(
@ -683,13 +677,13 @@ describe('modules/datasource/maven/index', () => {
const releaseOrig: Release = { version: '1.2.3' };
expect(
await postprocessRelease(
{ datasource, registryUrl: MAVEN_REPO },
{ datasource, registryUrl: MAVEN_REPO }, // packageName is missing
releaseOrig,
),
).toBe(releaseOrig);
expect(
await postprocessRelease(
{ datasource, packageName: 'foo:bar' },
{ datasource, packageName: 'foo:bar' }, // registryUrl is missing
releaseOrig,
),
).toBe(releaseOrig);
@ -698,7 +692,7 @@ describe('modules/datasource/maven/index', () => {
it('adds releaseTimestamp', async () => {
httpMock
.scope(MAVEN_REPO)
.head('/foo/bar/1.2.3/bar-1.2.3.pom')
.get('/foo/bar/1.2.3/bar-1.2.3.pom')
.reply(200, '', { 'Last-Modified': '2024-01-01T00:00:00.000Z' });
const res = await postprocessRelease(
@ -719,13 +713,22 @@ describe('modules/datasource/maven/index', () => {
s3mock.reset();
});
function body(input: string): ReturnType<typeof sdkStreamMixin> {
const result = new Readable();
result.push(input);
result.push(null);
return sdkStreamMixin(result);
}
it('checks package', async () => {
s3mock
.on(HeadObjectCommand, {
.on(GetObjectCommand, {
Bucket: 'bucket',
Key: 'foo/bar/1.2.3/bar-1.2.3.pom',
})
.resolvesOnce({});
.resolvesOnce({
Body: body('foo'),
});
const res = await postprocessRelease(
{ datasource, packageName: 'foo:bar', registryUrl: 's3://bucket' },
@ -737,11 +740,12 @@ describe('modules/datasource/maven/index', () => {
it('supports timestamp', async () => {
s3mock
.on(HeadObjectCommand, {
.on(GetObjectCommand, {
Bucket: 'bucket',
Key: 'foo/bar/1.2.3/bar-1.2.3.pom',
})
.resolvesOnce({
Body: body('foo'),
LastModified: DateTime.fromISO(
'2024-01-01T00:00:00.000Z',
).toJSDate(),
@ -760,7 +764,7 @@ describe('modules/datasource/maven/index', () => {
it('returns null for deleted object', async () => {
s3mock
.on(HeadObjectCommand, {
.on(GetObjectCommand, {
Bucket: 'bucket',
Key: 'foo/bar/1.2.3/bar-1.2.3.pom',
})
@ -778,7 +782,7 @@ describe('modules/datasource/maven/index', () => {
it('returns null for NotFound response', async () => {
s3mock
.on(HeadObjectCommand, {
.on(GetObjectCommand, {
Bucket: 'bucket',
Key: 'foo/bar/1.2.3/bar-1.2.3.pom',
})
@ -796,7 +800,7 @@ describe('modules/datasource/maven/index', () => {
it('returns null for NoSuchKey response', async () => {
s3mock
.on(HeadObjectCommand, {
.on(GetObjectCommand, {
Bucket: 'bucket',
Key: 'foo/bar/1.2.3/bar-1.2.3.pom',
})
@ -812,9 +816,9 @@ describe('modules/datasource/maven/index', () => {
expect(res).toBeNull();
});
it('returns null for unknown error', async () => {
it('returns original value for any other error', async () => {
s3mock
.on(HeadObjectCommand, {
.on(GetObjectCommand, {
Bucket: 'bucket',
Key: 'foo/bar/1.2.3/bar-1.2.3.pom',
})
@ -827,7 +831,7 @@ describe('modules/datasource/maven/index', () => {
releaseOrig,
);
expect(res).toBeNull();
expect(res).toBe(releaseOrig);
});
});
});

View file

@ -1,9 +1,9 @@
import is from '@sindresorhus/is';
import type { XmlDocument } from 'xmldoc';
import { GlobalConfig } from '../../../config/global';
import { logger } from '../../../logger';
import * as packageCache from '../../../util/cache/package';
import { cache } from '../../../util/cache/package/decorator';
import { Result } from '../../../util/result';
import { ensureTrailingSlash } from '../../../util/url';
import mavenVersion from '../../versioning/maven';
import * as mavenVersioning from '../../versioning/maven';
@ -18,10 +18,10 @@ import type {
ReleaseResult,
} from '../types';
import { MAVEN_REPO } from './common';
import type { MavenDependency } from './types';
import type { MavenDependency, MavenFetchError } from './types';
import {
checkResource,
createUrlForDependencyPom,
downloadMaven,
downloadMavenXml,
getDependencyInfo,
getDependencyParts,
@ -93,24 +93,29 @@ export class MavenDatasource extends Datasource {
return cachedVersions;
}
const { isCacheable, xml: mavenMetadata } = await downloadMavenXml(
this.http,
metadataUrl,
);
if (!mavenMetadata) {
return [];
}
const metadataXmlResult = await downloadMavenXml(this.http, metadataUrl);
return metadataXmlResult
.transform(
async ({ isCacheable, data: mavenMetadata }): Promise<string[]> => {
const versions = extractVersions(mavenMetadata);
const cachePrivatePackages = GlobalConfig.get(
'cachePrivatePackages',
false,
);
const versions = extractVersions(mavenMetadata);
const cachePrivatePackages = GlobalConfig.get(
'cachePrivatePackages',
false,
);
if (cachePrivatePackages || isCacheable) {
await packageCache.set(cacheNamespace, cacheKey, versions, 30);
}
if (cachePrivatePackages || isCacheable) {
await packageCache.set(cacheNamespace, cacheKey, versions, 30);
}
return versions;
return versions;
},
)
.onError((err) => {
logger.debug(
`Maven: error fetching versions for "${dependency.display}": ${err.type}`,
);
})
.unwrapOr([]);
}
async getReleases({
@ -190,17 +195,22 @@ export class MavenDatasource extends Datasource {
);
const artifactUrl = getMavenUrl(dependency, registryUrl, pomUrl);
const fetchResult = await downloadMaven(this.http, artifactUrl);
return fetchResult
.transform((res): PostprocessReleaseResult => {
if (res.lastModified) {
release.releaseTimestamp = res.lastModified;
}
const res = await checkResource(this.http, artifactUrl);
return release;
})
.catch((err): Result<PostprocessReleaseResult, MavenFetchError> => {
if (err.type === 'not-found') {
return Result.ok('reject');
}
if (res === 'not-found' || res === 'error') {
return 'reject';
}
if (is.date(res)) {
release.releaseTimestamp = res.toISOString();
}
return release;
return Result.ok(release);
})
.unwrapOr(release);
}
}

View file

@ -1,4 +1,3 @@
import type { XmlDocument } from 'xmldoc';
import type { Result } from '../../../util/result';
import type { ReleaseResult } from '../types';
@ -9,13 +8,6 @@ export interface MavenDependency {
dependencyUrl: string;
}
export interface MavenXml {
isCacheable?: boolean;
xml?: XmlDocument;
}
export type HttpResourceCheckResult = 'found' | 'not-found' | 'error' | Date;
export type DependencyInfo = Pick<
ReleaseResult,
'homepage' | 'sourceUrl' | 'packageScope'
@ -41,6 +33,7 @@ export type MavenFetchError =
| { type: 'unsupported-protocol' }
| { type: 'credentials-error' }
| { type: 'missing-aws-region' }
| { type: 'xml-parse-error'; err: Error }
| { type: 'unknown'; err: Error };
export type MavenFetchResult<T = string> = Result<

View file

@ -4,7 +4,6 @@ import { HOST_DISABLED } from '../../../constants/error-messages';
import { Http, HttpError } from '../../../util/http';
import type { MavenFetchError } from './types';
import {
checkResource,
downloadHttpProtocol,
downloadMavenXml,
downloadS3Protocol,
@ -46,17 +45,36 @@ function httpError({
describe('modules/datasource/maven/util', () => {
describe('downloadMavenXml', () => {
it('returns empty object for unsupported protocols', async () => {
it('returns error for unsupported protocols', async () => {
const res = await downloadMavenXml(
http,
new URL('unsupported://server.com/'),
);
expect(res).toEqual({});
expect(res.unwrap()).toEqual({
ok: false,
err: { type: 'unsupported-protocol' } satisfies MavenFetchError,
});
});
it('returns error for xml parse error', async () => {
const http = partial<Http>({
get: () =>
Promise.resolve({
statusCode: 200,
body: 'invalid xml',
headers: {},
}),
});
const res = await downloadMavenXml(http, 'https://example.com/');
expect(res.unwrap()).toEqual({
ok: false,
err: { type: 'xml-parse-error', err: expect.any(Error) },
});
});
});
describe('downloadS3Protocol', () => {
it('fails for non-S3 URLs', async () => {
it('returns error for non-S3 URLs', async () => {
const res = await downloadS3Protocol(new URL('http://not-s3.com/'));
expect(res.unwrap()).toEqual({
ok: false,
@ -122,16 +140,4 @@ describe('modules/datasource/maven/util', () => {
});
});
});
describe('checkResource', () => {
it('returns not found for unsupported protocols', async () => {
const res = await checkResource(http, 'unsupported://server.com/');
expect(res).toBe('not-found');
});
it('returns error for invalid URLs', async () => {
const res = await checkResource(http, 'not-a-valid-url');
expect(res).toBe('error');
});
});
});

View file

@ -1,6 +1,5 @@
import { Readable } from 'node:stream';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { DateTime } from 'luxon';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { XmlDocument } from 'xmldoc';
import { HOST_DISABLED } from '../../../constants/error-messages';
import { logger } from '../../../logger';
@ -9,7 +8,6 @@ import { type Http, HttpError } from '../../../util/http';
import type { HttpOptions, HttpResponse } from '../../../util/http/types';
import { regEx } from '../../../util/regex';
import { Result } from '../../../util/result';
import type { S3UrlParts } from '../../../util/s3';
import { getS3Client, parseS3Url } from '../../../util/s3';
import { streamToString } from '../../../util/streams';
import { ensureTrailingSlash, parseUrl } from '../../../util/url';
@ -18,11 +16,9 @@ import { getGoogleAuthToken } from '../util';
import { MAVEN_REPO } from './common';
import type {
DependencyInfo,
HttpResourceCheckResult,
MavenDependency,
MavenFetchResult,
MavenFetchSuccess,
MavenXml,
} from './types';
function getHost(url: string): string | null {
@ -268,92 +264,6 @@ export async function downloadArtifactRegistryProtocol(
return downloadHttpProtocol(http, url, opts);
}
async function checkHttpResource(
http: Http,
pkgUrl: URL,
): Promise<HttpResourceCheckResult> {
try {
const res = await http.head(pkgUrl.toString());
const timestamp = res?.headers?.['last-modified'];
if (timestamp) {
const isoTimestamp = normalizeDate(timestamp);
if (isoTimestamp) {
const releaseDate = DateTime.fromISO(isoTimestamp, {
zone: 'UTC',
}).toJSDate();
return releaseDate;
}
}
return 'found';
} catch (err) {
if (isNotFoundError(err)) {
return 'not-found';
}
const failedUrl = pkgUrl.toString();
logger.debug(
{ failedUrl, statusCode: err.statusCode },
`Can't check HTTP resource existence`,
);
return 'error';
}
}
export async function checkS3Resource(
s3Url: S3UrlParts,
): Promise<HttpResourceCheckResult> {
try {
const response = await getS3Client().send(new HeadObjectCommand(s3Url));
if (response.DeleteMarker) {
return 'not-found';
}
if (response.LastModified) {
return response.LastModified;
}
return 'found';
} catch (err) {
if (isS3NotFound(err)) {
return 'not-found';
} else {
logger.debug(
{
bucket: s3Url.Bucket,
key: s3Url.Key,
name: err.name,
message: err.message,
},
`Can't check S3 resource existence`,
);
}
return 'error';
}
}
export async function checkResource(
http: Http,
pkgUrl: URL | string,
): Promise<HttpResourceCheckResult> {
const parsedUrl = typeof pkgUrl === 'string' ? parseUrl(pkgUrl) : pkgUrl;
if (parsedUrl === null) {
return 'error';
}
const s3Url = parseS3Url(parsedUrl);
if (s3Url) {
return await checkS3Resource(s3Url);
}
if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') {
return await checkHttpResource(http, parsedUrl);
}
logger.debug(
{ url: pkgUrl.toString() },
`Unsupported Maven protocol in check resource`,
);
return 'not-found';
}
function containsPlaceholder(str: string): boolean {
return regEx(/\${.*?}/g).test(str);
}
@ -369,44 +279,57 @@ export function getMavenUrl(
);
}
export async function downloadMaven(
http: Http,
url: URL | string,
): Promise<MavenFetchResult> {
const pkgUrl = url instanceof URL ? url : parseUrl(url);
// istanbul ignore if
if (!pkgUrl) {
return Result.err({ type: 'invalid-url' });
}
const protocol = pkgUrl.protocol.slice(0, -1);
let result: MavenFetchResult = Result.err({ type: 'unsupported-protocol' });
if (protocol === 'http' || protocol === 'https') {
result = await downloadHttpProtocol(http, pkgUrl);
}
if (protocol === 'artifactregistry') {
result = await downloadArtifactRegistryProtocol(http, pkgUrl);
}
if (protocol === 's3') {
result = await downloadS3Protocol(pkgUrl);
}
return result.onError((err) => {
if (err.type === 'unsupported-protocol') {
logger.debug(
{ url: pkgUrl.toString() },
`Maven lookup error: unsupported protocol (${protocol})`,
);
}
});
}
export async function downloadMavenXml(
http: Http,
pkgUrl: URL,
): Promise<MavenXml> {
const protocol = pkgUrl.protocol;
if (protocol === 'http:' || protocol === 'https:') {
const rawResult = await downloadHttpProtocol(http, pkgUrl);
const xmlResult = rawResult.transform(({ isCacheable, data }): MavenXml => {
const xml = new XmlDocument(data);
return { isCacheable, xml };
});
return xmlResult.unwrapOr({});
}
if (protocol === 'artifactregistry:') {
const rawResult = await downloadArtifactRegistryProtocol(http, pkgUrl);
const xmlResult = rawResult.transform(({ isCacheable, data }): MavenXml => {
const xml = new XmlDocument(data);
return { isCacheable, xml };
});
return xmlResult.unwrapOr({});
}
if (protocol === 's3:') {
const rawResult = await downloadS3Protocol(pkgUrl);
const xmlResult = rawResult.transform(({ isCacheable, data }): MavenXml => {
const xml = new XmlDocument(data);
return { xml };
});
return xmlResult.unwrapOr({});
}
logger.debug(
{ url: pkgUrl.toString() },
`Content is not found for Maven url`,
);
return {};
url: URL | string,
): Promise<MavenFetchResult<XmlDocument>> {
const rawResult = await downloadMaven(http, url);
return rawResult.transform((result): MavenFetchResult<XmlDocument> => {
try {
return Result.ok({
...result,
data: new XmlDocument(result.data),
});
} catch (err) {
return Result.err({ type: 'xml-parse-error', err });
}
});
}
export function getDependencyParts(packageName: string): MavenDependency {
@ -458,13 +381,14 @@ async function getSnapshotFullVersion(
`${version}/maven-metadata.xml`,
);
const { xml: mavenMetadata } = await downloadMavenXml(http, metadataUrl);
// istanbul ignore if: hard to test
if (!mavenMetadata) {
return null;
}
const metadataXmlResult = await downloadMavenXml(http, metadataUrl);
return extractSnapshotVersion(mavenMetadata);
return metadataXmlResult
.transform(({ data }) => {
const nullErr = { type: 'snapshot-extract-error' };
return Result.wrapNullable(extractSnapshotVersion(data), nullErr);
})
.unwrapOrNull();
}
function isSnapshotVersion(version: string): boolean {
@ -508,7 +432,6 @@ export async function getDependencyInfo(
version: string,
recursionLimit = 5,
): Promise<DependencyInfo> {
const result: DependencyInfo = {};
const path = await createUrlForDependencyPom(
http,
version,
@ -517,64 +440,71 @@ export async function getDependencyInfo(
);
const pomUrl = getMavenUrl(dependency, repoUrl, path);
const { xml: pomContent } = await downloadMavenXml(http, pomUrl);
// istanbul ignore if
if (!pomContent) {
return result;
}
const pomXmlResult = await downloadMavenXml(http, pomUrl);
const dependencyInfoResult = await pomXmlResult.transform(
async ({ data: pomContent }) => {
const result: DependencyInfo = {};
const homepage = pomContent.valueWithPath('url');
if (homepage && !containsPlaceholder(homepage)) {
result.homepage = homepage;
}
const sourceUrl = pomContent.valueWithPath('scm.url');
if (sourceUrl && !containsPlaceholder(sourceUrl)) {
result.sourceUrl = sourceUrl
.replace(regEx(/^scm:/), '')
.replace(regEx(/^git:/), '')
.replace(regEx(/^git@github.com:/), 'https://github.com/')
.replace(regEx(/^git@github.com\//), 'https://github.com/');
if (result.sourceUrl.startsWith('//')) {
// most likely the result of us stripping scm:, git: etc
// going with prepending https: here which should result in potential information retrival
result.sourceUrl = `https:${result.sourceUrl}`;
}
}
const groupId = pomContent.valueWithPath('groupId');
if (groupId) {
result.packageScope = groupId;
}
const parent = pomContent.childNamed('parent');
if (recursionLimit > 0 && parent && (!result.sourceUrl || !result.homepage)) {
// if we found a parent and are missing some information
// trying to get the scm/homepage information from it
const [parentGroupId, parentArtifactId, parentVersion] = [
'groupId',
'artifactId',
'version',
].map((k) => parent.valueWithPath(k)?.replace(/\s+/g, ''));
if (parentGroupId && parentArtifactId && parentVersion) {
const parentDisplayId = `${parentGroupId}:${parentArtifactId}`;
const parentDependency = getDependencyParts(parentDisplayId);
const parentInformation = await getDependencyInfo(
http,
parentDependency,
repoUrl,
parentVersion,
recursionLimit - 1,
);
if (!result.sourceUrl && parentInformation.sourceUrl) {
result.sourceUrl = parentInformation.sourceUrl;
const homepage = pomContent.valueWithPath('url');
if (homepage && !containsPlaceholder(homepage)) {
result.homepage = homepage;
}
if (!result.homepage && parentInformation.homepage) {
result.homepage = parentInformation.homepage;
}
}
}
return result;
const sourceUrl = pomContent.valueWithPath('scm.url');
if (sourceUrl && !containsPlaceholder(sourceUrl)) {
result.sourceUrl = sourceUrl
.replace(regEx(/^scm:/), '')
.replace(regEx(/^git:/), '')
.replace(regEx(/^git@github.com:/), 'https://github.com/')
.replace(regEx(/^git@github.com\//), 'https://github.com/');
if (result.sourceUrl.startsWith('//')) {
// most likely the result of us stripping scm:, git: etc
// going with prepending https: here which should result in potential information retrival
result.sourceUrl = `https:${result.sourceUrl}`;
}
}
const groupId = pomContent.valueWithPath('groupId');
if (groupId) {
result.packageScope = groupId;
}
const parent = pomContent.childNamed('parent');
if (
recursionLimit > 0 &&
parent &&
(!result.sourceUrl || !result.homepage)
) {
// if we found a parent and are missing some information
// trying to get the scm/homepage information from it
const [parentGroupId, parentArtifactId, parentVersion] = [
'groupId',
'artifactId',
'version',
].map((k) => parent.valueWithPath(k)?.replace(/\s+/g, ''));
if (parentGroupId && parentArtifactId && parentVersion) {
const parentDisplayId = `${parentGroupId}:${parentArtifactId}`;
const parentDependency = getDependencyParts(parentDisplayId);
const parentInformation = await getDependencyInfo(
http,
parentDependency,
repoUrl,
parentVersion,
recursionLimit - 1,
);
if (!result.sourceUrl && parentInformation.sourceUrl) {
result.sourceUrl = parentInformation.sourceUrl;
}
if (!result.homepage && parentInformation.homepage) {
result.homepage = parentInformation.homepage;
}
}
}
return result;
},
);
return dependencyInfoResult.unwrapOr({});
}

View file

@ -269,6 +269,7 @@
"@openpgp/web-stream-tools": "0.1.3",
"@renovate/eslint-plugin": "file:tools/eslint",
"@semantic-release/exec": "6.0.3",
"@smithy/util-stream": "3.3.4",
"@swc/core": "1.10.4",
"@types/auth-header": "1.0.6",
"@types/aws4": "1.11.6",

View file

@ -379,6 +379,9 @@ importers:
'@semantic-release/exec':
specifier: 6.0.3
version: 6.0.3(semantic-release@24.2.0(typescript@5.7.2))
'@smithy/util-stream':
specifier: 3.3.4
version: 3.3.4
'@swc/core':
specifier: 1.10.4
version: 1.10.4