Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nuget): allow detecting source URLs via package contents #28071

Merged
merged 14 commits into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/usage/self-hosted-experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ This feature is in private beta.
If set, Renovate will query the merge-confidence JSON API only for datasources that are part of this list.
The expected value for this environment variable is a JSON array of strings.

## `RENOVATE_X_NUGET_DOWNLOAD_NUPKGS`

If set to any value, Renovate will download `nupkg` files for determining package metadata.

## `RENOVATE_X_PLATFORM_VERSION`

Specify this string for Renovate to skip API checks and provide GitLab/Bitbucket server version directly.
Expand Down
Binary file not shown.
Binary file not shown.
8 changes: 6 additions & 2 deletions lib/modules/datasource/nuget/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ export function removeBuildMeta(version: string): string {

const urlWhitespaceRe = regEx(/\s/g);

export function massageUrl(url: string): string {
export function massageUrl(url: string | null | undefined): string | null {
if (url === null || url === undefined) {
return null;
}

let resultUrl = url;

// During `dotnet pack` certain URLs are being URL decoded which may introduce whitespaces
// During `dotnet pack` certain URLs are being URL decoded which may introduce whitespace
// and causes Markdown link generation problems.
resultUrl = resultUrl.replace(urlWhitespaceRe, '%20');

Expand Down
167 changes: 166 additions & 1 deletion lib/modules/datasource/nuget/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Readable } from 'stream';
import { mockDeep } from 'jest-mock-extended';
import { join } from 'upath';
import { getPkgReleases } from '..';
import { Fixtures } from '../../../../test/fixtures';
import * as httpMock from '../../../../test/http-mock';
import { logger } from '../../../../test/util';
import { logger, mocked } from '../../../../test/util';
import { GlobalConfig } from '../../../config/global';
import * as _packageCache from '../../../util/cache/package';
import * as _hostRules from '../../../util/host-rules';
import { id as versioning } from '../../versioning/nuget';
import { parseRegistryUrl } from './common';
Expand All @@ -14,6 +18,9 @@ const hostRules: any = _hostRules;

jest.mock('../../../util/host-rules', () => mockDeep());

jest.mock('../../../util/cache/package', () => mockDeep());
const packageCache = mocked(_packageCache);

const pkgInfoV3FromNuget = Fixtures.get('nunit/v3_nuget_org.xml');
const pkgListV3Registration = Fixtures.get('nunit/v3_registration.json');

Expand Down Expand Up @@ -105,6 +112,10 @@ const configV3AzureDevOps = {
};

describe('modules/datasource/nuget/index', () => {
beforeEach(() => {
GlobalConfig.reset();
});

describe('parseRegistryUrl', () => {
it('extracts feed version from registry URL hash (v3)', () => {
const parsed = parseRegistryUrl('https://my-registry#protocolVersion=3');
Expand Down Expand Up @@ -302,6 +313,160 @@ describe('modules/datasource/nuget/index', () => {
);
});

describe('determine source URL from nupkg', () => {
beforeEach(() => {
GlobalConfig.set({
fgreinacher marked this conversation as resolved.
Show resolved Hide resolved
cacheDir: join('/tmp/cache'),
});
process.env.RENOVATE_X_NUGET_DOWNLOAD_NUPKGS = 'true';
});

afterEach(() => {
delete process.env.RENOVATE_X_NUGET_DOWNLOAD_NUPKGS;
});

it('can determine source URL from nupkg when PackageBaseAddress is missing', async () => {
const nugetIndex = `
{
"version": "3.0.0",
"resources": [
{
"@id": "https://some-registry/v3/metadata",
"@type": "RegistrationsBaseUrl/3.0.0-beta",
"comment": "Get package metadata."
}
]
}
`;
const nlogRegistration = `
{
"count": 1,
"items": [
{
"@id": "https://some-registry/v3/metadata/nlog/4.7.3.json",
"lower": "4.7.3",
"upper": "4.7.3",
"count": 1,
"items": [
{
"@id": "foo",
"catalogEntry": {
"id": "NLog",
"version": "4.7.3",
"packageContent": "https://some-registry/v3-flatcontainer/nlog/4.7.3/nlog.4.7.3.nupkg"
}
}
]
}
]
}
`;
httpMock
.scope('https://some-registry')
.get('/v3/index.json')
.twice()
.reply(200, nugetIndex)
.get('/v3/metadata/nlog/index.json')
.reply(200, nlogRegistration)
.get('/v3-flatcontainer/nlog/4.7.3/nlog.4.7.3.nupkg')
.reply(200, () => {
const readableStream = new Readable();
readableStream.push(Fixtures.getBinary('nlog/NLog.4.7.3.nupkg'));
readableStream.push(null);
return readableStream;
});
const res = await getPkgReleases({
datasource,
versioning,
packageName: 'NLog',
registryUrls: ['https://some-registry/v3/index.json'],
});
expect(logger.logger.debug).toHaveBeenCalledWith(
'Determined sourceUrl https://github.com/NLog/NLog.git from https://some-registry/v3-flatcontainer/nlog/4.7.3/nlog.4.7.3.nupkg',
);
expect(packageCache.set).toHaveBeenCalledWith(
'datasource-nuget',
'cache-decorator:source-url:https://some-registry/v3/index.json:NLog',
{
cachedAt: expect.any(String),
value: 'https://github.com/NLog/NLog.git',
},
60 * 24 * 7,
);
expect(res?.sourceUrl).toBeDefined();
});

it('can handle nupkg without repository metadata', async () => {
const nugetIndex = `
{
"version": "3.0.0",
"resources": [
{
"@id": "https://some-registry/v3/metadata",
"@type": "RegistrationsBaseUrl/3.0.0-beta",
"comment": "Get package metadata."
}
]
}
`;
const nlogRegistration = `
{
"count": 1,
"items": [
{
"@id": "https://some-registry/v3/metadata/nlog/4.7.3.json",
"lower": "4.7.3",
"upper": "4.7.3",
"count": 1,
"items": [
{
"@id": "foo",
"catalogEntry": {
"id": "NLog",
"version": "4.7.3",
"packageContent": "https://some-registry/v3-flatcontainer/nlog/4.7.3/nlog.4.7.3.nupkg"
}
}
]
}
]
}
`;
httpMock
.scope('https://some-registry')
.get('/v3/index.json')
.twice()
.reply(200, nugetIndex)
.get('/v3/metadata/nlog/index.json')
.reply(200, nlogRegistration)
.get('/v3-flatcontainer/nlog/4.7.3/nlog.4.7.3.nupkg')
.reply(200, () => {
const readableStream = new Readable();
readableStream.push(
Fixtures.getBinary('nlog/NLog.4.7.3-no-repo.nupkg'),
);
readableStream.push(null);
return readableStream;
});
const res = await getPkgReleases({
datasource,
versioning,
packageName: 'NLog',
registryUrls: ['https://some-registry/v3/index.json'],
});
expect(packageCache.set).toHaveBeenCalledWith(
'datasource-nuget',
'cache-decorator:source-url:https://some-registry/v3/index.json:NLog',
{
cachedAt: expect.any(String),
value: null,
},
60 * 24 * 7,
);
expect(res?.sourceUrl).toBeUndefined();
});
});

it('returns null for non 200 (v3v2)', async () => {
httpMock.scope('https://api.nuget.org').get('/v3/index.json').reply(500);
httpMock
Expand Down
2 changes: 2 additions & 0 deletions lib/modules/datasource/nuget/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ export interface ServicesIndexRaw {
}[];
}

// See https://learn.microsoft.com/en-us/nuget/api/registration-base-url-resource#catalog-entry
export interface CatalogEntry {
version: string;
published?: string;
projectUrl?: string;
listed?: boolean;
packageContent?: string;
}

export interface CatalogPage {
Expand Down
77 changes: 75 additions & 2 deletions lib/modules/datasource/nuget/v3.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import is from '@sindresorhus/is';
import extract from 'extract-zip';
import semver from 'semver';
import upath from 'upath';
import { XmlDocument } from 'xmldoc';
import { logger } from '../../../logger';
import { ExternalHostError } from '../../../types/errors/external-host-error';
import * as packageCache from '../../../util/cache/package';
import { cache } from '../../../util/cache/package/decorator';
import * as fs from '../../../util/fs';
import { ensureCacheDir } from '../../../util/fs';
import { Http, HttpError } from '../../../util/http';
import * as p from '../../../util/promises';
import { regEx } from '../../../util/regex';
Expand Down Expand Up @@ -151,15 +156,23 @@ export class NugetV3Api {

let homepage: string | null = null;
let latestStable: string | null = null;
let nupkgUrl: string | null = null;
const releases = catalogEntries.map(
({ version, published: releaseTimestamp, projectUrl, listed }) => {
({
version,
published: releaseTimestamp,
projectUrl,
listed,
packageContent,
}) => {
const release: Release = { version: removeBuildMeta(version) };
if (releaseTimestamp) {
release.releaseTimestamp = releaseTimestamp;
}
if (versioning.isValid(version) && versioning.isStable(version)) {
latestStable = removeBuildMeta(version);
homepage = projectUrl ? massageUrl(projectUrl) : homepage;
nupkgUrl = massageUrl(packageContent);
}
if (listed === false) {
release.isDeprecated = true;
Expand All @@ -177,6 +190,7 @@ export class NugetV3Api {
const last = catalogEntries.pop()!;
latestStable = removeBuildMeta(last.version);
homepage ??= last.projectUrl ?? null;
nupkgUrl ??= massageUrl(last.packageContent);
}

const dep: ReleaseResult = {
Expand All @@ -189,7 +203,6 @@ export class NugetV3Api {
registryUrl,
'PackageBaseAddress',
);
// istanbul ignore else: this is a required v3 api
if (is.nonEmptyString(packageBaseAddress)) {
const nuspecUrl = `${ensureTrailingSlash(
packageBaseAddress,
Expand All @@ -203,6 +216,18 @@ export class NugetV3Api {
if (sourceUrl) {
dep.sourceUrl = massageUrl(sourceUrl);
}
} else if (nupkgUrl) {
const sourceUrl = await this.getSourceUrlFromNupkg(
http,
registryUrl,
pkgName,
latestStable,
nupkgUrl,
);
if (sourceUrl) {
dep.sourceUrl = massageUrl(sourceUrl);
logger.debug(`Determined sourceUrl ${sourceUrl} from ${nupkgUrl}`);
}
}
} catch (err) {
// istanbul ignore if: not easy testable with nock
Expand Down Expand Up @@ -233,4 +258,52 @@ export class NugetV3Api {

return dep;
}

@cache({
namespace: NugetV3Api.cacheNamespace,
key: (
_http: Http,
registryUrl: string,
packageName: string,
_packageVersion: string | null,
rarkins marked this conversation as resolved.
Show resolved Hide resolved
_nupkgUrl: string,
) => `source-url:${registryUrl}:${packageName}`,
ttlMinutes: 10080, // 1 week
})
async getSourceUrlFromNupkg(
http: Http,
_registryUrl: string,
viceice marked this conversation as resolved.
Show resolved Hide resolved
packageName: string,
packageVersion: string | null,
nupkgUrl: string,
): Promise<string | null> {
// istanbul ignore if: experimental feature
if (!process.env.RENOVATE_X_NUGET_DOWNLOAD_NUPKGS) {
logger.once.debug('RENOVATE_X_NUGET_DOWNLOAD_NUPKGS is not set');
return null;
}
const cacheDir = await ensureCacheDir('nuget');
const nupkgFile = upath.join(
cacheDir,
`${packageName}.${packageVersion}.nupkg`,
);
const nupkgContentsDir = upath.join(
cacheDir,
`${packageName}.${packageVersion}`,
);
const readStream = http.stream(nupkgUrl);
try {
const writeStream = fs.createCacheWriteStream(nupkgFile);
await fs.pipeline(readStream, writeStream);
await extract(nupkgFile, { dir: nupkgContentsDir });
const nuspecFile = upath.join(nupkgContentsDir, `${packageName}.nuspec`);
const nuspec = new XmlDocument(
await fs.readCacheFile(nuspecFile, 'utf8'),
);
return nuspec.valueWithPath('metadata.repository@url') ?? null;
} finally {
await fs.rmCache(nupkgFile);
await fs.rmCache(nupkgContentsDir);
}
}
}