From a94466c4ee4c9e44ab2021ace57e8554b82351b5 Mon Sep 17 00:00:00 2001
From: Florian Greinacher <florian@greinacher.de>
Date: Sat, 20 Apr 2024 08:21:19 +0200
Subject: [PATCH] feat(nuget): allow detecting source URLs via package contents
 (#28071)

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 docs/usage/self-hosted-experimental.md        |   4 +
 .../nlog/NLog.4.7.3-no-repo.nupkg             | Bin 0 -> 2201 bytes
 .../nuget/__fixtures__/nlog/NLog.4.7.3.nupkg  | Bin 0 -> 2227 bytes
 lib/modules/datasource/nuget/common.ts        |   8 +-
 lib/modules/datasource/nuget/index.spec.ts    | 167 +++++++++++++++++-
 lib/modules/datasource/nuget/types.ts         |   2 +
 lib/modules/datasource/nuget/v3.ts            |  77 +++++++-
 7 files changed, 253 insertions(+), 5 deletions(-)
 create mode 100644 lib/modules/datasource/nuget/__fixtures__/nlog/NLog.4.7.3-no-repo.nupkg
 create mode 100644 lib/modules/datasource/nuget/__fixtures__/nlog/NLog.4.7.3.nupkg

diff --git a/docs/usage/self-hosted-experimental.md b/docs/usage/self-hosted-experimental.md
index bae03e095f..04124a9ba7 100644
--- a/docs/usage/self-hosted-experimental.md
+++ b/docs/usage/self-hosted-experimental.md
@@ -141,6 +141,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.
diff --git a/lib/modules/datasource/nuget/__fixtures__/nlog/NLog.4.7.3-no-repo.nupkg b/lib/modules/datasource/nuget/__fixtures__/nlog/NLog.4.7.3-no-repo.nupkg
new file mode 100644
index 0000000000000000000000000000000000000000..77ce3d810f1adeed673f4a98354a14b11797947a
GIT binary patch
literal 2201
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
Jpv3{=0RYH|8h`)*

literal 0
HcmV?d00001

diff --git a/lib/modules/datasource/nuget/__fixtures__/nlog/NLog.4.7.3.nupkg b/lib/modules/datasource/nuget/__fixtures__/nlog/NLog.4.7.3.nupkg
new file mode 100644
index 0000000000000000000000000000000000000000..6ec85f1ec046510454834a7881f0ac9e28af0780
GIT binary patch
literal 2227
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

literal 0
HcmV?d00001

diff --git a/lib/modules/datasource/nuget/common.ts b/lib/modules/datasource/nuget/common.ts
index e3c9afb28f..14bb38d694 100644
--- a/lib/modules/datasource/nuget/common.ts
+++ b/lib/modules/datasource/nuget/common.ts
@@ -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');
 
diff --git a/lib/modules/datasource/nuget/index.spec.ts b/lib/modules/datasource/nuget/index.spec.ts
index db7bbaa7b5..ce25cf7722 100644
--- a/lib/modules/datasource/nuget/index.spec.ts
+++ b/lib/modules/datasource/nuget/index.spec.ts
@@ -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';
@@ -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');
 
@@ -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');
@@ -302,6 +313,160 @@ describe('modules/datasource/nuget/index', () => {
       );
     });
 
+    describe('determine source URL from nupkg', () => {
+      beforeEach(() => {
+        GlobalConfig.set({
+          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
diff --git a/lib/modules/datasource/nuget/types.ts b/lib/modules/datasource/nuget/types.ts
index 29aba2a5c6..36fa672e83 100644
--- a/lib/modules/datasource/nuget/types.ts
+++ b/lib/modules/datasource/nuget/types.ts
@@ -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 {
diff --git a/lib/modules/datasource/nuget/v3.ts b/lib/modules/datasource/nuget/v3.ts
index 2024c175cb..1d0dc587c0 100644
--- a/lib/modules/datasource/nuget/v3.ts
+++ b/lib/modules/datasource/nuget/v3.ts
@@ -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';
@@ -151,8 +156,15 @@ 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;
@@ -160,6 +172,7 @@ export class NugetV3Api {
         if (versioning.isValid(version) && versioning.isStable(version)) {
           latestStable = removeBuildMeta(version);
           homepage = projectUrl ? massageUrl(projectUrl) : homepage;
+          nupkgUrl = massageUrl(packageContent);
         }
         if (listed === false) {
           release.isDeprecated = true;
@@ -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 = {
@@ -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,
@@ -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
@@ -233,4 +258,52 @@ export class NugetV3Api {
 
     return dep;
   }
+
+  @cache({
+    namespace: NugetV3Api.cacheNamespace,
+    key: (
+      _http: Http,
+      registryUrl: string,
+      packageName: string,
+      _packageVersion: string | null,
+      _nupkgUrl: string,
+    ) => `source-url:${registryUrl}:${packageName}`,
+    ttlMinutes: 10080, // 1 week
+  })
+  async getSourceUrlFromNupkg(
+    http: Http,
+    _registryUrl: string,
+    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);
+    }
+  }
 }
-- 
GitLab