From c8c8684ea3d957e3b843ec4a620fc54d9c2fb4b8 Mon Sep 17 00:00:00 2001
From: Michael Kriese <michael.kriese@visualon.de>
Date: Mon, 30 Aug 2021 18:28:32 +0200
Subject: [PATCH] fix(datasource): trim trailing slash in registry url (#11392)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
---
 lib/datasource/docker/index.spec.ts           | 26 ++++++++++
 .../__snapshots__/index.spec.ts.snap          | 50 ++++++++-----------
 lib/datasource/gitlab-tags/index.spec.ts      | 31 +++++++++++-
 lib/datasource/gitlab-tags/index.ts           | 14 ++++--
 lib/datasource/index.ts                       |  3 +-
 .../__snapshots__/index.spec.ts.snap          |  2 +-
 .../nuget/__snapshots__/index.spec.ts.snap    |  8 +--
 .../pypi/__snapshots__/index.spec.ts.snap     | 12 ++---
 .../repology/__snapshots__/index.spec.ts.snap | 12 ++---
 lib/datasource/repology/index.ts              |  8 +--
 .../rubygems/__snapshots__/index.spec.ts.snap |  2 +-
 lib/util/url.spec.ts                          | 17 +++++++
 lib/util/url.ts                               |  4 ++
 13 files changed, 130 insertions(+), 59 deletions(-)

diff --git a/lib/datasource/docker/index.spec.ts b/lib/datasource/docker/index.spec.ts
index c88d086521..038fde1a0b 100644
--- a/lib/datasource/docker/index.spec.ts
+++ b/lib/datasource/docker/index.spec.ts
@@ -607,6 +607,32 @@ describe('datasource/docker/index', () => {
       expect(httpMock.getTrace()).toMatchSnapshot();
     });
 
+    it('strips trailing slash from registry', async () => {
+      httpMock
+        .scope(baseUrl)
+        .get('/')
+        .reply(401, '', {
+          'www-authenticate':
+            'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:my/node:pull  "',
+        })
+        .get('/my/node/tags/list?n=10000')
+        .reply(200, { tags: ['1.0.0'] }, {})
+        .get('/')
+        .reply(200)
+        .get('/my/node/manifests/1.0.0')
+        .reply(200);
+      httpMock
+        .scope(authUrl)
+        .get('/token?service=registry.docker.io&scope=repository:my/node:pull')
+        .reply(200, { token: 'some-token ' });
+      const res = await getPkgReleases({
+        datasource: id,
+        depName: 'my/node',
+        registryUrls: ['https://index.docker.io/'],
+      });
+      expect(res?.releases).toHaveLength(1);
+    });
+
     it('returns null if no auth', async () => {
       hostRules.find.mockReturnValue({});
       httpMock.scope(baseUrl).get('/').reply(401, undefined, {
diff --git a/lib/datasource/gitlab-tags/__snapshots__/index.spec.ts.snap b/lib/datasource/gitlab-tags/__snapshots__/index.spec.ts.snap
index 4ab77e4ae0..4ab20c12a0 100644
--- a/lib/datasource/gitlab-tags/__snapshots__/index.spec.ts.snap
+++ b/lib/datasource/gitlab-tags/__snapshots__/index.spec.ts.snap
@@ -2,7 +2,7 @@
 
 exports[`datasource/gitlab-tags/index getReleases returns tags from custom registry 1`] = `
 Object {
-  "registryUrl": "https://gitlab.company.com/api/v4/",
+  "registryUrl": "https://gitlab.company.com/api/v4",
   "releases": Array [
     Object {
       "gitRef": "v1.0.0",
@@ -18,23 +18,30 @@ Object {
       "version": "v1.1.1",
     },
   ],
-  "sourceUrl": "https://gitlab.company.com/api/v4/some/dep2",
+  "sourceUrl": "https://gitlab.company.com/some/dep2",
 }
 `;
 
-exports[`datasource/gitlab-tags/index getReleases returns tags from custom registry 2`] = `
-Array [
-  Object {
-    "headers": Object {
-      "accept": "application/json",
-      "accept-encoding": "gzip, deflate, br",
-      "host": "gitlab.company.com",
-      "user-agent": "RenovateBot/0.0.0-semantic-release (https://github.com/renovatebot/renovate)",
+exports[`datasource/gitlab-tags/index getReleases returns tags from custom registry in sub path 1`] = `
+Object {
+  "registryUrl": "https://my.company.com/gitlab",
+  "releases": Array [
+    Object {
+      "gitRef": "v1.0.0",
+      "releaseTimestamp": "2020-03-04T18:01:37.000Z",
+      "version": "v1.0.0",
+    },
+    Object {
+      "gitRef": "v1.1.0",
+      "version": "v1.1.0",
     },
-    "method": "GET",
-    "url": "https://gitlab.company.com/api/v4/projects/some%2Fdep2/repository/tags?per_page=100",
-  },
-]
+    Object {
+      "gitRef": "v1.1.1",
+      "version": "v1.1.1",
+    },
+  ],
+  "sourceUrl": "https://my.company.com/gitlab/some/dep2",
+}
 `;
 
 exports[`datasource/gitlab-tags/index getReleases returns tags with default registry 1`] = `
@@ -53,18 +60,3 @@ Object {
   "sourceUrl": "https://gitlab.com/some/dep2",
 }
 `;
-
-exports[`datasource/gitlab-tags/index getReleases returns tags with default registry 2`] = `
-Array [
-  Object {
-    "headers": Object {
-      "accept": "application/json",
-      "accept-encoding": "gzip, deflate, br",
-      "host": "gitlab.com",
-      "user-agent": "RenovateBot/0.0.0-semantic-release (https://github.com/renovatebot/renovate)",
-    },
-    "method": "GET",
-    "url": "https://gitlab.com/api/v4/projects/some%2Fdep2/repository/tags?per_page=100",
-  },
-]
-`;
diff --git a/lib/datasource/gitlab-tags/index.spec.ts b/lib/datasource/gitlab-tags/index.spec.ts
index 76ea7a5933..0f468cf8a7 100644
--- a/lib/datasource/gitlab-tags/index.spec.ts
+++ b/lib/datasource/gitlab-tags/index.spec.ts
@@ -31,7 +31,35 @@ describe('datasource/gitlab-tags/index', () => {
       });
       expect(res).toMatchSnapshot();
       expect(res.releases).toHaveLength(3);
-      expect(httpMock.getTrace()).toMatchSnapshot();
+    });
+
+    it('returns tags from custom registry in sub path', async () => {
+      const body = [
+        {
+          name: 'v1.0.0',
+          commit: {
+            created_at: '2020-03-04T12:01:37.000-06:00',
+          },
+        },
+        {
+          name: 'v1.1.0',
+          commit: {},
+        },
+        {
+          name: 'v1.1.1',
+        },
+      ];
+      httpMock
+        .scope('https://my.company.com/gitlab')
+        .get('/api/v4/projects/some%2Fdep2/repository/tags?per_page=100')
+        .reply(200, body);
+      const res = await getPkgReleases({
+        datasource,
+        registryUrls: ['https://my.company.com/gitlab'],
+        depName: 'some/dep2',
+      });
+      expect(res).toMatchSnapshot();
+      expect(res?.releases).toHaveLength(3);
     });
 
     it('returns tags with default registry', async () => {
@@ -46,7 +74,6 @@ describe('datasource/gitlab-tags/index', () => {
       });
       expect(res).toMatchSnapshot();
       expect(res.releases).toHaveLength(2);
-      expect(httpMock.getTrace()).toMatchSnapshot();
     });
   });
 });
diff --git a/lib/datasource/gitlab-tags/index.ts b/lib/datasource/gitlab-tags/index.ts
index 6c93462dc3..bf3355c07b 100644
--- a/lib/datasource/gitlab-tags/index.ts
+++ b/lib/datasource/gitlab-tags/index.ts
@@ -1,6 +1,6 @@
-import URL from 'url';
 import * as packageCache from '../../util/cache/package';
 import { GitlabHttp } from '../../util/http/gitlab';
+import { joinUrlParts } from '../../util/url';
 import type { GetReleasesConfig, ReleaseResult } from '../types';
 import type { GitlabTag } from './types';
 
@@ -19,9 +19,11 @@ function getCacheKey(depHost: string, repo: string): string {
 }
 
 export async function getReleases({
-  registryUrl: depHost,
+  registryUrl,
   lookupName: repo,
 }: GetReleasesConfig): Promise<ReleaseResult | null> {
+  const depHost = registryUrl.replace(/\/api\/v4$/, '');
+
   const cachedResult = await packageCache.get<ReleaseResult>(
     cacheNamespace,
     getCacheKey(depHost, repo)
@@ -34,9 +36,11 @@ export async function getReleases({
   const urlEncodedRepo = encodeURIComponent(repo);
 
   // tag
-  const url = URL.resolve(
+  const url = joinUrlParts(
     depHost,
-    `/api/v4/projects/${urlEncodedRepo}/repository/tags?per_page=100`
+    `api/v4/projects`,
+    urlEncodedRepo,
+    `repository/tags?per_page=100`
   );
 
   const gitlabTags = (
@@ -46,7 +50,7 @@ export async function getReleases({
   ).body;
 
   const dependency: ReleaseResult = {
-    sourceUrl: URL.resolve(depHost, repo),
+    sourceUrl: joinUrlParts(depHost, repo),
     releases: null,
   };
   dependency.releases = gitlabTags.map(({ name, commit }) => ({
diff --git a/lib/datasource/index.ts b/lib/datasource/index.ts
index 1b12c8b5b8..1d914e800d 100644
--- a/lib/datasource/index.ts
+++ b/lib/datasource/index.ts
@@ -7,6 +7,7 @@ import * as memCache from '../util/cache/memory';
 import * as packageCache from '../util/cache/package';
 import { clone } from '../util/clone';
 import { regEx } from '../util/regex';
+import { trimTrailingSlash } from '../util/url';
 import * as allVersioning from '../versioning';
 import datasources from './api';
 import { addMetaData } from './metadata';
@@ -200,7 +201,7 @@ function resolveRegistryUrls(
   } else {
     registryUrls = [...defaultRegistryUrls];
   }
-  return registryUrls.filter(Boolean);
+  return registryUrls.filter(Boolean).map(trimTrailingSlash);
 }
 
 export function getDefaultVersioning(datasourceName: string): string {
diff --git a/lib/datasource/jenkins-plugins/__snapshots__/index.spec.ts.snap b/lib/datasource/jenkins-plugins/__snapshots__/index.spec.ts.snap
index 19e59eebf0..0a310669ee 100644
--- a/lib/datasource/jenkins-plugins/__snapshots__/index.spec.ts.snap
+++ b/lib/datasource/jenkins-plugins/__snapshots__/index.spec.ts.snap
@@ -9,7 +9,7 @@ Object {
 
 exports[`datasource/jenkins-plugins/index getReleases returns package releases for a hit for info and releases 1`] = `
 Object {
-  "registryUrl": "https://updates.jenkins.io/",
+  "registryUrl": "https://updates.jenkins.io",
   "releases": Array [
     Object {
       "downloadUrl": "http://updates.jenkins-ci.org/download/plugins/email-ext/2.10/email-ext.hpi",
diff --git a/lib/datasource/nuget/__snapshots__/index.spec.ts.snap b/lib/datasource/nuget/__snapshots__/index.spec.ts.snap
index 8e5f4830f0..2b976f37d4 100644
--- a/lib/datasource/nuget/__snapshots__/index.spec.ts.snap
+++ b/lib/datasource/nuget/__snapshots__/index.spec.ts.snap
@@ -67,7 +67,7 @@ Array [
 
 exports[`datasource/nuget/index getReleases handles paginated results (v2) 1`] = `
 Object {
-  "registryUrl": "https://www.nuget.org/api/v2/",
+  "registryUrl": "https://www.nuget.org/api/v2",
   "releases": Array [
     Object {
       "version": "1.0.0",
@@ -104,7 +104,7 @@ Array [
 
 exports[`datasource/nuget/index getReleases processes real data (v2) 1`] = `
 Object {
-  "registryUrl": "https://www.nuget.org/api/v2/",
+  "registryUrl": "https://www.nuget.org/api/v2",
   "releases": Array [
     Object {
       "releaseTimestamp": "2011-01-07T07:57:55.387Z",
@@ -2052,7 +2052,7 @@ Array [
 
 exports[`datasource/nuget/index getReleases processes real data with no github project url (v2) 1`] = `
 Object {
-  "registryUrl": "https://www.nuget.org/api/v2/",
+  "registryUrl": "https://www.nuget.org/api/v2",
   "releases": Array [
     Object {
       "version": "3.11.0",
@@ -2078,7 +2078,7 @@ Array [
 
 exports[`datasource/nuget/index getReleases processes real data without project url (v2) 1`] = `
 Object {
-  "registryUrl": "https://www.nuget.org/api/v2/",
+  "registryUrl": "https://www.nuget.org/api/v2",
   "releases": Array [
     Object {
       "version": "2.5.7.10213",
diff --git a/lib/datasource/pypi/__snapshots__/index.spec.ts.snap b/lib/datasource/pypi/__snapshots__/index.spec.ts.snap
index 623cd7804a..a3bccdc7c1 100644
--- a/lib/datasource/pypi/__snapshots__/index.spec.ts.snap
+++ b/lib/datasource/pypi/__snapshots__/index.spec.ts.snap
@@ -83,7 +83,7 @@ Array [
 
 exports[`datasource/pypi/index getReleases parses data-requires-python and respects constraints from simple endpoint 1`] = `
 Object {
-  "registryUrl": "https://pypi.org/simple/",
+  "registryUrl": "https://pypi.org/simple",
   "releases": Array [
     Object {
       "version": "0.1.2",
@@ -123,7 +123,7 @@ Array [
 
 exports[`datasource/pypi/index getReleases process data from +simple endpoint 1`] = `
 Object {
-  "registryUrl": "https://some.registry.org/+simple/",
+  "registryUrl": "https://some.registry.org/+simple",
   "releases": Array [
     Object {
       "version": "0.1.2",
@@ -179,7 +179,7 @@ Array [
 
 exports[`datasource/pypi/index getReleases process data from simple endpoint 1`] = `
 Object {
-  "registryUrl": "https://pypi.org/simple/",
+  "registryUrl": "https://pypi.org/simple",
   "releases": Array [
     Object {
       "version": "0.1.2",
@@ -235,7 +235,7 @@ Array [
 
 exports[`datasource/pypi/index getReleases process data from simple endpoint with hyphens replaced with underscores 1`] = `
 Object {
-  "registryUrl": "https://pypi.org/simple/",
+  "registryUrl": "https://pypi.org/simple",
   "releases": Array [
     Object {
       "version": "0.0.5",
@@ -260,7 +260,7 @@ Array [
 
 exports[`datasource/pypi/index getReleases processes real data 1`] = `
 Object {
-  "registryUrl": "https://pypi.org/pypi/",
+  "registryUrl": "https://pypi.org/pypi",
   "releases": Array [
     Object {
       "releaseTimestamp": "2017-04-03T16:55:14.000Z",
@@ -373,7 +373,7 @@ Array [
 
 exports[`datasource/pypi/index getReleases respects constraints 1`] = `
 Object {
-  "registryUrl": "https://pypi.org/pypi/",
+  "registryUrl": "https://pypi.org/pypi",
   "releases": Array [
     Object {
       "version": "0.4.0",
diff --git a/lib/datasource/repology/__snapshots__/index.spec.ts.snap b/lib/datasource/repology/__snapshots__/index.spec.ts.snap
index a21d22412b..f31c48d616 100644
--- a/lib/datasource/repology/__snapshots__/index.spec.ts.snap
+++ b/lib/datasource/repology/__snapshots__/index.spec.ts.snap
@@ -2,7 +2,7 @@
 
 exports[`datasource/repology/index getReleases returns correct version for api package 1`] = `
 Object {
-  "registryUrl": "https://repology.org/",
+  "registryUrl": "https://repology.org",
   "releases": Array [
     Object {
       "version": "1.181",
@@ -38,7 +38,7 @@ Array [
 
 exports[`datasource/repology/index getReleases returns correct version for binary package 1`] = `
 Object {
-  "registryUrl": "https://repology.org/",
+  "registryUrl": "https://repology.org",
   "releases": Array [
     Object {
       "version": "1.14.2-2+deb10u1",
@@ -64,7 +64,7 @@ Array [
 
 exports[`datasource/repology/index getReleases returns correct version for multi-package project with different name 1`] = `
 Object {
-  "registryUrl": "https://repology.org/",
+  "registryUrl": "https://repology.org",
   "releases": Array [
     Object {
       "version": "12.2-4+deb10u1",
@@ -90,7 +90,7 @@ Array [
 
 exports[`datasource/repology/index getReleases returns correct version for multi-package project with same name 1`] = `
 Object {
-  "registryUrl": "https://repology.org/",
+  "registryUrl": "https://repology.org",
   "releases": Array [
     Object {
       "version": "9.3.0-r2",
@@ -116,7 +116,7 @@ Array [
 
 exports[`datasource/repology/index getReleases returns correct version for source package 1`] = `
 Object {
-  "registryUrl": "https://repology.org/",
+  "registryUrl": "https://repology.org",
   "releases": Array [
     Object {
       "version": "1.181",
@@ -152,7 +152,7 @@ Array [
 
 exports[`datasource/repology/index getReleases returns multiple versions if they are present in repository 1`] = `
 Object {
-  "registryUrl": "https://repology.org/",
+  "registryUrl": "https://repology.org",
   "releases": Array [
     Object {
       "version": "1:11.0.7.10-1.el8_1",
diff --git a/lib/datasource/repology/index.ts b/lib/datasource/repology/index.ts
index fd571da101..53fb346682 100644
--- a/lib/datasource/repology/index.ts
+++ b/lib/datasource/repology/index.ts
@@ -3,7 +3,7 @@ import { logger } from '../../logger';
 import { ExternalHostError } from '../../types/errors/external-host-error';
 import * as packageCache from '../../util/cache/package';
 import { Http } from '../../util/http';
-import { getQueryString } from '../../util/url';
+import { getQueryString, joinUrlParts } from '../../util/url';
 import type { GetReleasesConfig, ReleaseResult } from '../types';
 import type { RepologyPackage, RepologyPackageType } from './types';
 
@@ -52,7 +52,7 @@ async function queryPackagesViaResolver(
 
   // Retrieve list of packages by looking up Repology project
   const packages = await queryPackages(
-    `${registryUrl}tools/project-by?${query}`
+    joinUrlParts(registryUrl, `tools/project-by?${query}`)
   );
 
   return packages;
@@ -65,7 +65,7 @@ async function queryPackagesViaAPI(
   // Directly query the package via the API. This will only work if `packageName` has the
   // same name as the repology project
   const packages = await queryPackages(
-    `${registryUrl}api/v1/project/${packageName}`
+    joinUrlParts(registryUrl, `api/v1/project`, packageName)
   );
 
   return packages;
@@ -170,7 +170,7 @@ async function getCachedPackage(
   pkgName: string
 ): Promise<RepologyPackage[]> {
   // Fetch previous result from cache if available
-  const cacheKey = `${registryUrl}${repoName}/${pkgName}`;
+  const cacheKey = joinUrlParts(registryUrl, repoName, pkgName);
   const cachedResult = await packageCache.get<RepologyPackage[]>(
     cacheNamespace,
     cacheKey
diff --git a/lib/datasource/rubygems/__snapshots__/index.spec.ts.snap b/lib/datasource/rubygems/__snapshots__/index.spec.ts.snap
index 2f12ed4e3c..11742fb103 100644
--- a/lib/datasource/rubygems/__snapshots__/index.spec.ts.snap
+++ b/lib/datasource/rubygems/__snapshots__/index.spec.ts.snap
@@ -1461,7 +1461,7 @@ Array [
 exports[`datasource/rubygems/index getReleases uses multiple source urls 1`] = `
 Object {
   "homepage": "http://rubyonrails.org",
-  "registryUrl": "https://firstparty.com/basepath/",
+  "registryUrl": "https://firstparty.com/basepath",
   "releases": Array [
     Object {
       "releaseTimestamp": "2009-07-25T18:01:56.000Z",
diff --git a/lib/util/url.spec.ts b/lib/util/url.spec.ts
index c425ef9eb7..d6f959e8b9 100644
--- a/lib/util/url.spec.ts
+++ b/lib/util/url.spec.ts
@@ -1,5 +1,6 @@
 import {
   ensurePathPrefix,
+  joinUrlParts,
   parseUrl,
   resolveBaseUrl,
   trimTrailingSlash,
@@ -89,4 +90,20 @@ describe('util/url', () => {
       ensurePathPrefix('https://index.docker.io/v2/something', '/v2')
     ).toBe('https://index.docker.io/v2/something');
   });
+
+  it('joinUrlParts', () => {
+    const registryUrl = 'https://some.test';
+    expect(joinUrlParts(registryUrl, 'foo')).toBe(`${registryUrl}/foo`);
+    expect(joinUrlParts(registryUrl, '/?foo')).toBe(`${registryUrl}?foo`);
+    expect(joinUrlParts(registryUrl, '/foo/bar/')).toBe(
+      `${registryUrl}/foo/bar/`
+    );
+    expect(joinUrlParts(`${registryUrl}/foo/`, '/foo/bar')).toBe(
+      `${registryUrl}/foo/foo/bar`
+    );
+    expect(joinUrlParts(`${registryUrl}/api/`, '/foo/bar')).toBe(
+      `${registryUrl}/api/foo/bar`
+    );
+    expect(joinUrlParts('foo//////')).toBe('foo/');
+  });
 });
diff --git a/lib/util/url.ts b/lib/util/url.ts
index b84548b241..ea9e560eb2 100644
--- a/lib/util/url.ts
+++ b/lib/util/url.ts
@@ -1,5 +1,9 @@
 import urlJoin from 'url-join';
 
+export function joinUrlParts(...parts: string[]): string {
+  return urlJoin(...parts);
+}
+
 export function ensurePathPrefix(url: string, prefix: string): string {
   const parsed = new URL(url);
   const fullPath = url.replace(parsed.origin, '');
-- 
GitLab