From e0465365039c1ef33746262c476db432f9128236 Mon Sep 17 00:00:00 2001
From: FidoX <ieb.core@gmail.com>
Date: Mon, 7 Jan 2019 09:44:47 +0000
Subject: [PATCH] feat: maven datasource (WIP) (#2995)

feat: maven datasource
---
 lib/datasource/index.js                       |   2 +
 lib/datasource/maven.js                       | 151 ++++++++++++++++
 .../mysql-connector-java/maven-metadata.xml   |  12 ++
 .../mysql-connector-java/maven-metadata.xml   |  17 ++
 .../hamcrest/hamcrest-core/maven-metadata.xml |  19 ++
 test/datasource/maven.spec.js                 | 171 ++++++++++++++++++
 6 files changed, 372 insertions(+)
 create mode 100644 lib/datasource/maven.js
 create mode 100644 test/_fixtures/gradle/maven/custom_maven_repo/maven2/mysql/mysql-connector-java/maven-metadata.xml
 create mode 100644 test/_fixtures/gradle/maven/repo1.maven.org/maven2/mysql/mysql-connector-java/maven-metadata.xml
 create mode 100644 test/_fixtures/gradle/maven/repo1.maven.org/maven2/org/hamcrest/hamcrest-core/maven-metadata.xml
 create mode 100644 test/datasource/maven.spec.js

diff --git a/lib/datasource/index.js b/lib/datasource/index.js
index 1110167862..d9765f8bb5 100644
--- a/lib/datasource/index.js
+++ b/lib/datasource/index.js
@@ -12,6 +12,7 @@ const pypi = require('./pypi');
 const terraform = require('./terraform');
 const gitlab = require('./gitlab');
 const cargo = require('./cargo');
+const maven = require('./maven');
 
 const { addMetaData } = require('./metadata');
 
@@ -21,6 +22,7 @@ const datasources = {
   github,
   gitlab,
   go,
+  maven,
   npm,
   nuget,
   orb,
diff --git a/lib/datasource/maven.js b/lib/datasource/maven.js
new file mode 100644
index 0000000000..9a4c197ed9
--- /dev/null
+++ b/lib/datasource/maven.js
@@ -0,0 +1,151 @@
+const _ = require('lodash');
+const got = require('got');
+const url = require('url');
+const fs = require('fs-extra');
+const xmlParser = require('fast-xml-parser');
+
+module.exports = {
+  getPkgReleases,
+};
+
+// eslint-disable-next-line no-unused-vars
+async function getPkgReleases(purl, config) {
+  const versions = [];
+  const dependency = getDependencyParts(purl);
+  const repositories = getRepositories(purl);
+  if (repositories.length < 1) {
+    logger.error(`No repositories defined for ${dependency.display}`);
+    return null;
+  }
+  logger.debug(
+    `Found ${repositories.length} repositories for ${dependency.display}`
+  );
+  for (let i = 0; i < repositories.length; i += 1) {
+    const repoUrl = repositories[i];
+    logger.debug(
+      `Looking up ${dependency.display} in repository #${i} - ${repoUrl}`
+    );
+    const mavenMetadata = await downloadMavenMetadata(dependency, repoUrl);
+    if (mavenMetadata) {
+      const newVersions = extractVersions(mavenMetadata).filter(
+        version => !versions.includes(version)
+      );
+      versions.push(...newVersions);
+      logger.debug(`Found ${newVersions.length} new versions for ${dependency.display} in repository ${repoUrl}`); // prettier-ignore
+    }
+  }
+
+  if (versions.length === 0) {
+    logger.warn(`No versions found for ${dependency.display} in ${repositories.length} repositories`); // prettier-ignore
+    return null;
+  }
+  logger.debug(`Found ${versions.length} versions for ${dependency.display}`);
+
+  return {
+    ...dependency,
+    releases: versions.map(v => ({ version: v })),
+  };
+}
+
+function getDependencyParts(purl) {
+  return {
+    display: `${purl.namespace}:${purl.name}`,
+    group: purl.namespace,
+    name: purl.name,
+    version: purl.version,
+    dependencyUrl: generateMavenUrl(purl),
+  };
+}
+
+function getRepositories(purl) {
+  if (!purl.qualifiers || !purl.qualifiers.repository_url) {
+    return [];
+  }
+  return purl.qualifiers.repository_url.split(',').map(repoUrl => {
+    if (!repoUrl.endsWith('/')) {
+      return repoUrl + '/';
+    }
+    return repoUrl;
+  });
+}
+
+async function downloadMavenMetadata(dependency, repoUrl) {
+  const pkgUrl = new url.URL(
+    `${dependency.dependencyUrl}/maven-metadata.xml`,
+    repoUrl
+  );
+  let mavenMetadata;
+  switch (pkgUrl.protocol) {
+    case 'file:':
+      mavenMetadata = await downloadFileProtocol(pkgUrl);
+      break;
+    case 'http:':
+    case 'https:':
+      mavenMetadata = await downloadHttpProtocol(pkgUrl);
+      break;
+    default:
+      logger.error(
+        `Invalid protocol ${pkgUrl.protocol} in repository ${repoUrl}`
+      );
+      return null;
+  }
+  if (!mavenMetadata) {
+    logger.debug(`${dependency.display} not found in repository ${repoUrl}`);
+  }
+  return mavenMetadata;
+}
+
+function extractVersions(mavenMetadata) {
+  const doc = xmlParser.parse(mavenMetadata);
+  return _.get(doc, 'metadata.versioning.versions.version', []).map(v =>
+    String(v)
+  );
+}
+
+async function downloadFileProtocol(pkgUrl) {
+  const pkgPath = pkgUrl.toString().replace('file://', '');
+  if (!(await fs.exists(pkgPath))) {
+    return null;
+  }
+  return fs.readFile(pkgPath, 'utf8');
+}
+
+async function downloadHttpProtocol(pkgUrl) {
+  let raw;
+  try {
+    raw = await got(pkgUrl);
+  } catch (err) {
+    if (isNotFoundError(err)) {
+      logger.debug(`Url not found ${pkgUrl}`);
+    } else if (isTemporalError(err)) {
+      logger.warn(`Error requesting ${pkgUrl} Error Code: ${err.statusCode}`);
+      if (isMavenCentral(pkgUrl)) {
+        throw new Error('registry-failure');
+      }
+    } else {
+      logger.warn(
+        `Unknown error requesting ${pkgUrl} Error Code: ${err.statusCode}`
+      );
+    }
+    return null;
+  }
+  return raw.body;
+}
+
+function generateMavenUrl(purl) {
+  return purl.namespace.replace(/\./g, '/') + `/${purl.name}`;
+}
+
+function isMavenCentral(pkgUrl) {
+  return pkgUrl.host === 'central.maven.org';
+}
+
+function isTemporalError(err) {
+  return (
+    err.statusCode === 429 || (err.statusCode > 500 && err.statusCode < 600)
+  );
+}
+
+function isNotFoundError(err) {
+  return err.statusCode === 404;
+}
diff --git a/test/_fixtures/gradle/maven/custom_maven_repo/maven2/mysql/mysql-connector-java/maven-metadata.xml b/test/_fixtures/gradle/maven/custom_maven_repo/maven2/mysql/mysql-connector-java/maven-metadata.xml
new file mode 100644
index 0000000000..38781a726f
--- /dev/null
+++ b/test/_fixtures/gradle/maven/custom_maven_repo/maven2/mysql/mysql-connector-java/maven-metadata.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?><metadata>
+  <groupId>mysql</groupId>
+  <artifactId>mysql-connector-java</artifactId>
+  <version>6.0.5</version>
+  <versioning>
+    <versions>
+      <version>6.0.5</version>
+      <version>6.0.4</version>
+    </versions>
+    <lastUpdated>20130301200000</lastUpdated>
+  </versioning>
+</metadata>
diff --git a/test/_fixtures/gradle/maven/repo1.maven.org/maven2/mysql/mysql-connector-java/maven-metadata.xml b/test/_fixtures/gradle/maven/repo1.maven.org/maven2/mysql/mysql-connector-java/maven-metadata.xml
new file mode 100644
index 0000000000..4c3bd0fea3
--- /dev/null
+++ b/test/_fixtures/gradle/maven/repo1.maven.org/maven2/mysql/mysql-connector-java/maven-metadata.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?><metadata>
+  <groupId>mysql</groupId>
+  <artifactId>mysql-connector-java</artifactId>
+  <version>8.0.12</version>
+  <versioning>
+    <versions>
+      <version>8.0.12</version>
+      <version>8.0.11</version>
+      <version>8.0.9</version>
+      <version>8.0.8</version>
+      <version>8.0.7</version>
+      <version>6.0.6</version>
+      <version>6.0.5</version>
+    </versions>
+    <lastUpdated>20130301200000</lastUpdated>
+  </versioning>
+</metadata>
diff --git a/test/_fixtures/gradle/maven/repo1.maven.org/maven2/org/hamcrest/hamcrest-core/maven-metadata.xml b/test/_fixtures/gradle/maven/repo1.maven.org/maven2/org/hamcrest/hamcrest-core/maven-metadata.xml
new file mode 100644
index 0000000000..027fecd11a
--- /dev/null
+++ b/test/_fixtures/gradle/maven/repo1.maven.org/maven2/org/hamcrest/hamcrest-core/maven-metadata.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<metadata modelVersion="1.1.0">
+  <groupId>org.hamcrest</groupId>
+  <artifactId>hamcrest-core</artifactId>
+  <versioning>
+    <latest>2.1-rc3</latest>
+    <release>2.1-rc3</release>
+    <versions>
+      <version>1.1</version>
+      <version>1.2</version>
+      <version>1.2.1</version>
+      <version>1.3.RC2</version>
+      <version>1.3</version>
+      <version>2.1-rc2</version>
+      <version>2.1-rc3</version>
+    </versions>
+    <lastUpdated>20181129182853</lastUpdated>
+  </versioning>
+</metadata>
diff --git a/test/datasource/maven.spec.js b/test/datasource/maven.spec.js
new file mode 100644
index 0000000000..f98d4d6283
--- /dev/null
+++ b/test/datasource/maven.spec.js
@@ -0,0 +1,171 @@
+const nock = require('nock');
+const fs = require('fs');
+
+const datasource = require('../../lib/datasource');
+const { initLogger } = require('../../lib/logger');
+
+initLogger();
+
+const MYSQL_VERSIONS = [
+  '6.0.5',
+  '6.0.6',
+  '8.0.7',
+  '8.0.8',
+  '8.0.9',
+  '8.0.11',
+  '8.0.12',
+];
+
+const MYSQL_MAVEN_METADATA = fs.readFileSync(
+  'test/_fixtures/gradle/maven/repo1.maven.org/maven2/mysql/mysql-connector-java/maven-metadata.xml',
+  'utf8'
+);
+
+const config = {
+  versionScheme: 'loose',
+};
+
+describe('datasource/maven', () => {
+  beforeEach(() => {
+    nock('http://central.maven.org')
+      .get('/maven2/mysql/mysql-connector-java/maven-metadata.xml')
+      .reply(200, MYSQL_MAVEN_METADATA);
+    nock('http://failed_repo')
+      .get('/mysql/mysql-connector-java/maven-metadata.xml')
+      .reply(404, null);
+  });
+
+  describe('getPkgReleases', () => {
+    it('should return empty if library is not found', async () => {
+      const releases = await datasource.getPkgReleases(
+        'pkg:maven/unknown/unknown@1.0.5?repository_url=file://test/_fixtures/gradle/maven/repo1.maven.org/maven2/',
+        config
+      );
+      expect(releases).toBeNull();
+    });
+
+    it('should return all versions of a specific library', async () => {
+      const releases = await datasource.getPkgReleases(
+        'pkg:maven/org.hamcrest/hamcrest-core@1.2?repository_url=file://test/_fixtures/gradle/maven/repo1.maven.org/maven2/,file://test/_fixtures/gradle/maven/custom_maven_repo/maven2/',
+        config
+      );
+      expect(releases.releases).toEqual(
+        generateReleases([
+          '1.1',
+          '1.2',
+          '1.2.1',
+          '1.3',
+          '1.3.RC2',
+          '2.1-rc2',
+          '2.1-rc3',
+        ])
+      );
+    });
+
+    it('should return versions in all repositories for a specific library', async () => {
+      const releases = await datasource.getPkgReleases(
+        'pkg:maven/mysql/mysql-connector-java@6.0.5?repository_url=file://test/_fixtures/gradle/maven/repo1.maven.org/maven2/,file://test/_fixtures/gradle/maven/custom_maven_repo/maven2/',
+        config
+      );
+      expect(releases.releases).toEqual(
+        generateReleases(['6.0.4', ...MYSQL_VERSIONS])
+      );
+    });
+
+    it('should return all versions of a specific library for http repositories', async () => {
+      const releases = await datasource.getPkgReleases(
+        'pkg:maven/mysql/mysql-connector-java@6.0.5?repository_url=http://central.maven.org/maven2/',
+        config
+      );
+      expect(releases.releases).toEqual(generateReleases(MYSQL_VERSIONS));
+    });
+
+    it('should return all versions of a specific library if a repository fails', async () => {
+      const releases = await datasource.getPkgReleases(
+        'pkg:maven/mysql/mysql-connector-java@6.0.5?repository_url=http://central.maven.org/maven2/,http://failed_repo/,http://dns_error_repo',
+        config
+      );
+      expect(releases.releases).toEqual(generateReleases(MYSQL_VERSIONS));
+    });
+
+    it('should throw registry-failure if maven-central fails', async () => {
+      nock('http://central.maven.org')
+        .get('/maven2/org/artifact/maven-metadata.xml')
+        .times(4)
+        .reply(503);
+
+      expect.assertions(1);
+      try {
+        await datasource.getPkgReleases(
+          'pkg:maven/org/artifact@6.0.5?repository_url=http://central.maven.org/maven2/',
+          config
+        );
+      } catch (e) {
+        expect(e.message).toEqual('registry-failure');
+      }
+    });
+
+    it('should return all versions of a specific library if a repository fails because invalid protocol', async () => {
+      const releases = await datasource.getPkgReleases(
+        'pkg:maven/mysql/mysql-connector-java@6.0.5?repository_url=http://central.maven.org/maven2/,http://failed_repo/,ftp://protocol_error_repo',
+        config
+      );
+      expect(releases.releases).toEqual(generateReleases(MYSQL_VERSIONS));
+    });
+
+    it('should return all versions of a specific library if a repository fails because invalid metadata file is found in another repository', async () => {
+      const invalidMavenMetadata = `
+        <?xml version="1.0" encoding="UTF-8"?><metadata>
+          <groupId>mysql</groupId>
+          <artifactId>mysql-connector-java</artifactId>
+          <version>8.0.12</version>
+          <versioning>
+            <lastUpdated>20130301200000</lastUpdated>
+          </versioning>
+        </metadata>      
+      `;
+      nock('http://invalid_metadata_repo')
+        .get('/maven2/mysql/mysql-connector-java/maven-metadata.xml')
+        .reply(200, invalidMavenMetadata);
+      const releases = await datasource.getPkgReleases(
+        'pkg:maven/mysql/mysql-connector-java@6.0.5?repository_url=http://central.maven.org/maven2/,http://invalid_metadata_repo/maven2/',
+        config
+      );
+      expect(releases.releases).toEqual(generateReleases(MYSQL_VERSIONS));
+    });
+
+    it('should return all versions of a specific library if a repository fails because a metadata file is not xml', async () => {
+      const invalidMavenMetadata = `
+        Invalid XML
+      `;
+      nock('http://invalid_metadata_repo')
+        .get('/maven2/mysql/mysql-connector-java/maven-metadata.xml')
+        .reply(200, invalidMavenMetadata);
+      const releases = await datasource.getPkgReleases(
+        'pkg:maven/mysql/mysql-connector-java@6.0.5?repository_url=http://central.maven.org/maven2/,http://invalid_metadata_repo/maven2/',
+        config
+      );
+      expect(releases.releases).toEqual(generateReleases(MYSQL_VERSIONS));
+    });
+
+    it('should return all versions of a specific library if a repository does not end with /', async () => {
+      const releases = await datasource.getPkgReleases(
+        'pkg:maven/mysql/mysql-connector-java@6.0.5?repository_url=http://central.maven.org/maven2',
+        config
+      );
+      expect(releases).not.toBeNull();
+    });
+
+    it('should return null if no repositories defined', async () => {
+      const releases = await datasource.getPkgReleases(
+        'pkg:maven/mysql/mysql-connector-java@6.0.5',
+        config
+      );
+      expect(releases).toBeNull();
+    });
+  });
+});
+
+function generateReleases(versions) {
+  return versions.map(v => ({ version: v }));
+}
-- 
GitLab