From 7891d8979f32ebd5c35018acc0094a9074f0b87f Mon Sep 17 00:00:00 2001
From: Fred Cox <mcfedr@gmail.com>
Date: Sat, 7 Jan 2023 14:44:38 +0000
Subject: [PATCH] fix(versioning/hashicorp): better parsing (#19652)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 .../versioning/hashicorp/convertor.spec.ts    |  68 ++++++++++++
 lib/modules/versioning/hashicorp/convertor.ts | 101 ++++++++++++++++++
 .../versioning/hashicorp/index.spec.ts        |  63 ++++++-----
 lib/modules/versioning/hashicorp/index.ts     |  73 ++++---------
 lib/modules/versioning/npm/index.spec.ts      |   2 +
 5 files changed, 228 insertions(+), 79 deletions(-)
 create mode 100644 lib/modules/versioning/hashicorp/convertor.spec.ts
 create mode 100644 lib/modules/versioning/hashicorp/convertor.ts

diff --git a/lib/modules/versioning/hashicorp/convertor.spec.ts b/lib/modules/versioning/hashicorp/convertor.spec.ts
new file mode 100644
index 0000000000..29e682fcc7
--- /dev/null
+++ b/lib/modules/versioning/hashicorp/convertor.spec.ts
@@ -0,0 +1,68 @@
+import { hashicorp2npm, npm2hashicorp } from './convertor';
+
+describe('modules/versioning/hashicorp/convertor', () => {
+  test.each`
+    hashicorp           | npm
+    ${'4.2.0'}          | ${'4.2.0'}
+    ${'4.2.0-alpha'}    | ${'4.2.0-alpha'}
+    ${'~> 4.0'}         | ${'^4.0'}
+    ${'~> 4.1'}         | ${'^4.1'}
+    ${'~> 4.0.0'}       | ${'~4.0.0'}
+    ${'~> 4.0.1'}       | ${'~4.0.1'}
+    ${'~> 4.1.0'}       | ${'~4.1.0'}
+    ${'~> 4.1.1'}       | ${'~4.1.1'}
+    ${'~> 4.0.0-alpha'} | ${'~4.0.0-alpha'}
+    ${'>= 4.0'}         | ${'>=4.0'}
+    ${'<= 4.0'}         | ${'<=4.0'}
+    ${'> 4.0'}          | ${'>4.0'}
+    ${'< 4.0'}          | ${'<4.0'}
+    ${'> 4.0, < 5.0'}   | ${'>4.0 <5.0'}
+    ${'~> 2.3.4'}       | ${'~2.3.4'}
+  `(
+    'hashicorp2npm("$hashicorp") === $npm && npm2hashicorp("$npm") === $hashicorp',
+    ({ hashicorp, npm }) => {
+      expect(hashicorp2npm(hashicorp)).toBe(npm);
+      expect(npm2hashicorp(npm)).toBe(hashicorp);
+    }
+  );
+
+  // These are non-reflective cases for hashicorp2npm
+  test.each`
+    hashicorp        | npm
+    ${'~> 4'}        | ${'>=4'}
+    ${'~> v4'}       | ${'>=4'}
+    ${'>= v4.0'}     | ${'>=4.0'}
+    ${'>=4.0'}       | ${'>=4.0'}
+    ${'<=4.0'}       | ${'<=4.0'}
+    ${'= 4.0'}       | ${'4.0'}
+    ${'> 4.0,< 5.0'} | ${'>4.0 <5.0'}
+  `('hashicorp2npm("$hashicorp") === $npm', ({ hashicorp, npm }) => {
+    expect(hashicorp2npm(hashicorp)).toBe(npm);
+  });
+
+  // These are non-reflective cases for npm2hashicorp
+  test.each`
+    hashicorp     | npm
+    ${'~> 4.0'}   | ${'^4'}
+    ${'~> 4.0'}   | ${'^4.0.0'}
+    ${'~> 4.1'}   | ${'^4.1.0'}
+    ${'~> 4.1'}   | ${'^4.1.1'}
+    ${'~> 4.0'}   | ${'~4'}
+    ${'~> 4.0.0'} | ${'~4.0'}
+    ${'~> 4.1.0'} | ${'~4.1'}
+  `('npm2hashicorp("$npm") === $hashicorp', ({ hashicorp, npm }) => {
+    expect(npm2hashicorp(npm)).toBe(hashicorp);
+  });
+
+  test('hashicorp2npm doesnt support !=', () => {
+    expect(() => hashicorp2npm('!= 4')).toThrow();
+  });
+
+  test('hashicorp2npm throws on invalid', () => {
+    expect(() => hashicorp2npm('^4')).toThrow();
+  });
+
+  test('npm2hashicorp throws on unsupported', () => {
+    expect(() => npm2hashicorp('4.x.x')).toThrow();
+  });
+});
diff --git a/lib/modules/versioning/hashicorp/convertor.ts b/lib/modules/versioning/hashicorp/convertor.ts
new file mode 100644
index 0000000000..5dabfb5414
--- /dev/null
+++ b/lib/modules/versioning/hashicorp/convertor.ts
@@ -0,0 +1,101 @@
+import { regEx } from '../../../util/regex';
+
+/**
+ * This can convert most hashicorp ranges to valid npm syntax
+ * The `!=` syntax is currently unsupported as there is no direct
+ * equivalent in npm and isn't widely used
+ * Also prerelease syntax is less well-defined for hashicorp and will
+ * cause issues if it is not semvar compatible as no attempts to convert it
+ * are made
+ */
+export function hashicorp2npm(input: string): string {
+  return input
+    .split(',')
+    .map((single) => {
+      const r = single.match(
+        regEx(/^\s*(|=|!=|>|<|>=|<=|~>)\s*v?((\d+)(\.\d+){0,2}[\w-+]*)\s*$/)
+      );
+      if (!r) {
+        throw new Error('invalid hashicorp constraint');
+      }
+      if (r[1] === '!=') {
+        throw new Error('unsupported != in hashicorp constraint');
+      }
+      return {
+        operator: r[1],
+        version: r[2],
+      };
+    })
+    .map(({ operator, version }) => {
+      switch (operator) {
+        case '=':
+          return version;
+        case '~>':
+          if (version.match(regEx(/^\d+$/))) {
+            return `>=${version}`;
+          }
+          if (version.match(regEx(/^\d+\.\d+$/))) {
+            return `^${version}`;
+          }
+          return `~${version}`;
+        default:
+          return `${operator}${version}`;
+      }
+    })
+    .join(' ');
+}
+
+/**
+ * This can convert a limited set of npm range syntax to hashicorp,
+ * it supports all the syntax that hashicorp2npm can output
+ * It cannot handle `*`, `1.x.x`, range with `-`, `||`
+ */
+export function npm2hashicorp(input: string): string {
+  return input
+    .split(' ')
+    .map((single) => {
+      const r = single.match(
+        regEx(/^(|>|<|>=|<=|~|\^)((\d+)(\.\d+){0,2}[\w-]*)$/)
+      );
+      if (!r) {
+        throw new Error('invalid npm constraint');
+      }
+      return {
+        operator: r[1],
+        version: r[2],
+      };
+    })
+    .map(({ operator, version }) => {
+      switch (operator) {
+        case '^': {
+          if (version.match(regEx(/^\d+$/))) {
+            return `~> ${version}.0`;
+          }
+          const withZero = version.match(regEx(/^(\d+\.\d+)\.0$/));
+          if (withZero) {
+            return `~> ${withZero[1]}`;
+          }
+          const nonZero = version.match(regEx(/^(\d+\.\d+)\.\d+$/));
+          if (nonZero) {
+            // not including`>= ${version}`, which makes this less accurate
+            // but makes the results cleaner
+            return `~> ${nonZero[1]}`;
+          }
+          return `~> ${version}`;
+        }
+        case '~':
+          if (version.match(regEx(/^\d+$/))) {
+            return `~> ${version}.0`;
+          }
+          if (version.match(regEx(/^\d+\.\d+$/))) {
+            return `~> ${version}.0`;
+          }
+          return `~> ${version}`;
+        case '':
+          return `${version}`;
+        default:
+          return `${operator} ${version}`;
+      }
+    })
+    .join(', ');
+}
diff --git a/lib/modules/versioning/hashicorp/index.spec.ts b/lib/modules/versioning/hashicorp/index.spec.ts
index 7eb2795c5b..cc6bcede70 100644
--- a/lib/modules/versioning/hashicorp/index.spec.ts
+++ b/lib/modules/versioning/hashicorp/index.spec.ts
@@ -26,6 +26,16 @@ describe('modules/versioning/hashicorp/index', () => {
   test.each`
     input                   | expected
     ${'>= 1.0.0, <= 2.0.0'} | ${true}
+    ${'~> 4'}               | ${true}
+    ${'~> 4.0'}             | ${true}
+    ${'~> 4.1'}             | ${true}
+    ${'~> 4.1.2'}           | ${true}
+    ${'=4'}                 | ${true}
+    ${'=4.0'}               | ${true}
+    ${'!=4.0'}              | ${false}
+    ${'>=4.1'}              | ${true}
+    ${'<=4.1.2'}            | ${true}
+    ${''}                   | ${false}
   `('isValid("$input") === $expected', ({ input, expected }) => {
     const res = !!semver.isValid(input);
     expect(res).toBe(expected);
@@ -54,35 +64,30 @@ describe('modules/versioning/hashicorp/index', () => {
   );
 
   test.each`
-    currentValue            | rangeStrategy        | currentVersion | newVersion  | expected
-    ${'~> 1.2'}             | ${'replace'}         | ${'1.2.3'}     | ${'2.0.7'}  | ${'~> 2.0'}
-    ${'~> 1.2.0'}           | ${'replace'}         | ${'1.2.3'}     | ${'2.0.7'}  | ${'~> 2.0.0'}
-    ${'~> 0.14.0'}          | ${'replace'}         | ${'0.14.1'}    | ${'0.15.0'} | ${'~> 0.15.0'}
-    ${'~> 0.14.0'}          | ${'replace'}         | ${'0.14.1'}    | ${'0.15.1'} | ${'~> 0.15.0'}
-    ${'~> 0.14.6'}          | ${'replace'}         | ${'0.14.6'}    | ${'0.15.0'} | ${'~> 0.15.0'}
-    ${'>= 1.0.0, <= 2.0.0'} | ${'widen'}           | ${'1.2.3'}     | ${'2.0.7'}  | ${'>= 1.0.0, <= 2.0.7'}
-    ${'0.14'}               | ${'replace'}         | ${'0.14.2'}    | ${'0.15.0'} | ${'0.15'}
-    ${'~> 0.14'}            | ${'replace'}         | ${'0.14.2'}    | ${'0.15.0'} | ${'~> 0.15'}
-    ${'~> 0.14'}            | ${'update-lockfile'} | ${'0.14.2'}    | ${'0.14.6'} | ${'~> 0.14'}
-    ${'~> 0.14'}            | ${'update-lockfile'} | ${'0.14.2'}    | ${'0.15.0'} | ${'~> 0.15'}
-    ${'~> 2.62.0'}          | ${'update-lockfile'} | ${'2.62.0'}    | ${'2.62.1'} | ${'~> 2.62.0'}
-    ${'~> 2.62.0'}          | ${'update-lockfile'} | ${'2.62.0'}    | ${'2.67.0'} | ${'~> 2.67.0'}
-  `(
-    'getNewValue("$currentValue", "$rangeStrategy", "$currentVersion", "$newVersion") === "$expected"',
-    ({ currentValue, rangeStrategy, currentVersion, newVersion, expected }) => {
-      const res = semver.getNewValue({
-        currentValue,
-        rangeStrategy,
-        currentVersion,
-        newVersion,
-      });
-      expect(res).toEqual(expected);
-    }
-  );
-
-  test.each`
-    currentValue | rangeStrategy | currentVersion | newVersion   | expected
-    ${'v0.14'}   | ${'replace'}  | ${'v0.14.2'}   | ${'v0.15.0'} | ${'v0.15'}
+    currentValue            | rangeStrategy        | currentVersion | newVersion   | expected
+    ${'~> 1.2'}             | ${'replace'}         | ${'1.2.3'}     | ${'2.0.7'}   | ${'~> 2.0'}
+    ${'~> 1.2.0'}           | ${'replace'}         | ${'1.2.3'}     | ${'2.0.7'}   | ${'~> 2.0.0'}
+    ${'~> 1.2'}             | ${'replace'}         | ${'1.2.3'}     | ${'1.2.3'}   | ${'~> 1.2'}
+    ${'~> 1.2'}             | ${'replace'}         | ${'1.2.3'}     | ${'1.2.4'}   | ${'~> 1.2'}
+    ${'~> 1.2.0'}           | ${'replace'}         | ${'1.2.3'}     | ${'1.2.3'}   | ${'~> 1.2.0'}
+    ${'~> 0.14.0'}          | ${'replace'}         | ${'0.14.1'}    | ${'0.15.0'}  | ${'~> 0.15.0'}
+    ${'~> 0.14.0'}          | ${'replace'}         | ${'0.14.1'}    | ${'0.15.1'}  | ${'~> 0.15.0'}
+    ${'~> 0.14.6'}          | ${'replace'}         | ${'0.14.6'}    | ${'0.15.0'}  | ${'~> 0.15.0'}
+    ${'~> 0.14.0'}          | ${'replace'}         | ${'0.14.1'}    | ${'0.14.2'}  | ${'~> 0.14.0'}
+    ${'~> 0.14.6'}          | ${'replace'}         | ${'0.14.6'}    | ${'0.14.7'}  | ${'~> 0.14.0'}
+    ${'~> 2.3.4'}           | ${'replace'}         | ${'2.3.4'}     | ${'2.3.5'}   | ${'~> 2.3.0'}
+    ${'~> 0.14.0'}          | ${'bump'}            | ${'0.14.1'}    | ${'0.14.2'}  | ${'~> 0.14.2'}
+    ${'~> 0.14.6'}          | ${'bump'}            | ${'0.14.6'}    | ${'0.14.7'}  | ${'~> 0.14.7'}
+    ${'~> 0.14.6'}          | ${'bump'}            | ${'0.14.6'}    | ${'0.15.1'}  | ${'~> 0.15.1'}
+    ${'~> 0.14.6'}          | ${'bump'}            | ${'0.14.6'}    | ${'2.0.7'}   | ${'~> 2.0.7'}
+    ${'>= 1.0.0, <= 2.0.0'} | ${'widen'}           | ${'1.2.3'}     | ${'2.0.7'}   | ${'>= 1.0.0, <= 2.0.7'}
+    ${'0.14'}               | ${'replace'}         | ${'0.14.2'}    | ${'0.15.0'}  | ${'0.15'}
+    ${'~> 0.14'}            | ${'replace'}         | ${'0.14.2'}    | ${'0.15.0'}  | ${'~> 0.15'}
+    ${'~> 0.14'}            | ${'update-lockfile'} | ${'0.14.2'}    | ${'0.14.6'}  | ${'~> 0.14'}
+    ${'~> 0.14'}            | ${'update-lockfile'} | ${'0.14.2'}    | ${'0.15.0'}  | ${'~> 0.15'}
+    ${'~> 2.62.0'}          | ${'update-lockfile'} | ${'2.62.0'}    | ${'2.62.1'}  | ${'~> 2.62.0'}
+    ${'~> 2.62.0'}          | ${'update-lockfile'} | ${'2.62.0'}    | ${'2.67.0'}  | ${'~> 2.67.0'}
+    ${'v0.14'}              | ${'replace'}         | ${'v0.14.2'}   | ${'v0.15.0'} | ${'v0.15'}
   `(
     'getNewValue("$currentValue", "$rangeStrategy", "$currentVersion", "$newVersion") === "$expected"',
     ({ currentValue, rangeStrategy, currentVersion, newVersion, expected }) => {
diff --git a/lib/modules/versioning/hashicorp/index.ts b/lib/modules/versioning/hashicorp/index.ts
index 9ad144ce73..c736d22b42 100644
--- a/lib/modules/versioning/hashicorp/index.ts
+++ b/lib/modules/versioning/hashicorp/index.ts
@@ -1,7 +1,7 @@
 import type { RangeStrategy } from '../../../types/versioning';
-import { regEx } from '../../../util/regex';
 import { api as npm } from '../npm';
 import type { NewValueConfig, VersioningApi } from '../types';
+import { hashicorp2npm, npm2hashicorp } from './convertor';
 
 export const id = 'hashicorp';
 export const displayName = 'Hashicorp';
@@ -16,39 +16,37 @@ export const supportedRangeStrategies: RangeStrategy[] = [
   'replace',
 ];
 
-function hashicorp2npm(input: string): string {
-  // The only case incompatible with semver is a "short" ~>, e.g. ~> 1.2
-  return input.replace(regEx(/~>(\s*\d+\.\d+$)/), '^$1').replace(',', '');
-}
-
 function isLessThanRange(version: string, range: string): boolean {
-  return !!npm.isLessThanRange?.(hashicorp2npm(version), hashicorp2npm(range));
+  return !!npm.isLessThanRange?.(version, hashicorp2npm(range));
 }
 
-export const isValid = (input: string): boolean =>
-  !!input && npm.isValid(hashicorp2npm(input));
+export function isValid(input: string): boolean {
+  if (input) {
+    try {
+      return npm.isValid(hashicorp2npm(input));
+    } catch (err) {
+      return false;
+    }
+  }
+  return false;
+}
 
-const matches = (version: string, range: string): boolean =>
-  npm.matches(hashicorp2npm(version), hashicorp2npm(range));
+function matches(version: string, range: string): boolean {
+  return npm.matches(version, hashicorp2npm(range));
+}
 
 function getSatisfyingVersion(
   versions: string[],
   range: string
 ): string | null {
-  return npm.getSatisfyingVersion(
-    versions.map(hashicorp2npm),
-    hashicorp2npm(range)
-  );
+  return npm.getSatisfyingVersion(versions, hashicorp2npm(range));
 }
 
 function minSatisfyingVersion(
   versions: string[],
   range: string
 ): string | null {
-  return npm.minSatisfyingVersion(
-    versions.map(hashicorp2npm),
-    hashicorp2npm(range)
-  );
+  return npm.minSatisfyingVersion(versions, hashicorp2npm(range));
 }
 
 function getNewValue({
@@ -57,42 +55,17 @@ function getNewValue({
   currentVersion,
   newVersion,
 }: NewValueConfig): string | null {
-  if (['replace', 'update-lockfile'].includes(rangeStrategy)) {
-    const minor = npm.getMinor(newVersion);
-    const major = npm.getMajor(newVersion);
-    if (regEx(/~>\s*0\.\d+/).test(currentValue) && major === 0 && minor) {
-      const testFullVersion = regEx(/(~>\s*0\.)(\d+)\.\d$/);
-      let replaceValue = '';
-      if (testFullVersion.test(currentValue)) {
-        replaceValue = `$<prefix>${minor}.0`;
-      } else {
-        replaceValue = `$<prefix>${minor}$<suffix>`;
-      }
-      return currentValue.replace(
-        regEx(`(?<prefix>~>\\s*0\\.)\\d+(?<suffix>.*)$`),
-        replaceValue
-      );
-    }
-    // handle special ~> 1.2 case
-    if (major && regEx(/(~>\s*)\d+\.\d+$/).test(currentValue)) {
-      return currentValue.replace(
-        regEx(`(?<prefix>~>\\s*)\\d+\\.\\d+$`),
-        `$<prefix>${major}.0`
-      );
-    }
-  }
   let npmNewVersion = npm.getNewValue({
-    currentValue,
+    currentValue: hashicorp2npm(currentValue),
     rangeStrategy,
     currentVersion,
     newVersion,
   });
-  if (
-    npmNewVersion &&
-    currentValue.startsWith('v') &&
-    !npmNewVersion.startsWith('v')
-  ) {
-    npmNewVersion = `v${npmNewVersion}`;
+  if (npmNewVersion) {
+    npmNewVersion = npm2hashicorp(npmNewVersion);
+    if (currentValue.startsWith('v') && !npmNewVersion.startsWith('v')) {
+      npmNewVersion = `v${npmNewVersion}`;
+    }
   }
   return npmNewVersion;
 }
diff --git a/lib/modules/versioning/npm/index.spec.ts b/lib/modules/versioning/npm/index.spec.ts
index 53183b4135..8330064d65 100644
--- a/lib/modules/versioning/npm/index.spec.ts
+++ b/lib/modules/versioning/npm/index.spec.ts
@@ -108,6 +108,8 @@ describe('modules/versioning/npm/index', () => {
     ${'^0.2.3'}             | ${'replace'}         | ${'0.2.3'}       | ${'0.2.4'}              | ${'^0.2.3'}
     ${'^2.3.0'}             | ${'replace'}         | ${'2.3.0'}       | ${'2.4.0'}              | ${'^2.3.0'}
     ${'^2.3.4'}             | ${'replace'}         | ${'2.3.4'}       | ${'2.4.5'}              | ${'^2.3.4'}
+    ${'^2.3.4'}             | ${'replace'}         | ${'2.3.4'}       | ${'2.3.5'}              | ${'^2.3.4'}
+    ${'~2.3.4'}             | ${'replace'}         | ${'2.3.4'}       | ${'2.3.5'}              | ${'~2.3.0'}
     ${'^0.0.1'}             | ${'replace'}         | ${'0.0.1'}       | ${'0.0.2'}              | ${'^0.0.2'}
     ${'^1.0.1'}             | ${'replace'}         | ${'1.0.1'}       | ${'2.0.2'}              | ${'^2.0.0'}
     ${'^1.2.3'}             | ${'replace'}         | ${'1.2.3'}       | ${'1.2.3'}              | ${'^1.2.3'}
-- 
GitLab