diff --git a/CHANGELOG.md b/CHANGELOG.md index acb0f51..dc01080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The changelog starts with version 1.7.0. Changes to prior versions can be found on the [Github release page](https://github.com/cryptomator/integrations-api/releases). -## [Unreleased] +## [Unreleased](https://github.com/cryptomator/integrations-api/compare/1.7.0...HEAD) -No changes yet. +### Added -## [1.7.0] - 2025-09-17 +* Experimental [Update API](https://github.com/cryptomator/integrations-api/blob/a052dd06a38f5410f6d9c9c7061c036efee83480/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java) + +## [1.7.0](https://github.com/cryptomator/integrations-api/releases/tag/1.7.0) - 2025-09-17 ### Changed diff --git a/pom.xml b/pom.xml index d29bd0d..9b6c098 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ 5.13.4 - 5.19.0 + 5.20.0 3.14.0 diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 9925772..ea65cc4 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -6,11 +6,13 @@ import org.cryptomator.integrations.keychain.KeychainAccessProvider; import org.cryptomator.integrations.tray.TrayIntegrationProvider; import org.cryptomator.integrations.uiappearance.UiAppearanceProvider; +import org.cryptomator.integrations.update.UpdateMechanism; module org.cryptomator.integrations.api { requires static org.jetbrains.annotations; requires org.slf4j; + requires java.net.http; exports org.cryptomator.integrations.autostart; exports org.cryptomator.integrations.common; @@ -20,6 +22,7 @@ exports org.cryptomator.integrations.tray; exports org.cryptomator.integrations.uiappearance; exports org.cryptomator.integrations.quickaccess; + exports org.cryptomator.integrations.update; uses AutoStartProvider; uses KeychainAccessProvider; @@ -29,4 +32,5 @@ uses TrayMenuController; uses UiAppearanceProvider; uses QuickAccessService; + uses UpdateMechanism; } \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java b/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java index a75febd..c0f5116 100644 --- a/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java +++ b/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java @@ -36,6 +36,19 @@ public static Optional load(Class clazz) { return loadAll(clazz).findFirst(); } + /** + * Loads a specific service provider by its implementation class name. + * @param clazz Service class + * @param implementationClassName fully qualified class name of the implementation + * @return Optional of the service provider if found + * @param Type of the service + */ + public static Optional loadSpecific(Class clazz, String implementationClassName) { + return ServiceLoader.load(clazz, ClassLoaderFactory.forPluginDir()).stream() + .filter(provider -> provider.type().getName().equals(implementationClassName)) + .map(ServiceLoader.Provider::get) + .findAny(); + } /** * Loads all suited service providers ordered by priority in descending order. diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java new file mode 100644 index 0000000..8e6b415 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java @@ -0,0 +1,176 @@ +package org.cryptomator.integrations.update; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; + +public abstract class DownloadUpdateStep implements UpdateStep { + + protected final URI source; + protected final Path destination; + private final byte[] checksum; + private final AtomicLong totalBytes; + private final LongAdder loadedBytes = new LongAdder(); + private final Thread downloadThread; + private final CountDownLatch downloadCompleted = new CountDownLatch(1); + protected volatile IOException downloadException; + + /** + * Creates a new DownloadUpdateProcess instance. + * @param source The URI from which the update will be downloaded. + * @param destination The path to theworking directory where the downloaded file will be saved. + * @param checksum (optional) The expected SHA-256 checksum of the downloaded file, can be null if not required. + * @param estDownloadSize The estimated size of the download in bytes. + */ + protected DownloadUpdateStep(URI source, Path destination, byte[] checksum, long estDownloadSize) { + this.source = source; + this.destination = destination; + this.checksum = checksum; + this.totalBytes = new AtomicLong(estDownloadSize); + this.downloadThread = Thread.ofVirtual().unstarted(this::download); + } + + @Override + public String description() { + return switch (downloadThread.getState()) { + case NEW -> "Download... "; + case TERMINATED -> "Downloaded."; + default -> "Downloading... %1.0f%%".formatted(preparationProgress() * 100); + }; + } + + @Override + public void start() { + downloadThread.start(); + } + + @Override + public double preparationProgress() { + long total = totalBytes.get(); + if (total <= 0) { + return -1.0; + } else { + return (double) loadedBytes.sum() / totalBytes.get(); + } + } + + @Override + public void await() throws InterruptedException { + downloadCompleted.await(); + } + + @Override + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return downloadCompleted.await(timeout, unit); + } + + @Override + public void cancel() { + downloadThread.interrupt(); + } + + protected void download() { + var request = HttpRequest.newBuilder().uri(source).GET().build(); + try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build()) { + downloadInternal(client, request); + } catch (IOException e) { + downloadException = e; + } finally { + downloadCompleted.countDown(); + } + } + + /** + * Downloads the update from the given URI and saves it to the specified filename in the working directory. + * @param client the HttpClient to use for the download + * @param request the HttpRequest which downloads the file + * @throws IOException indicating I/O errors during the download or file writing process or due to checksum mismatch + */ + protected void downloadInternal(HttpClient client, HttpRequest request) throws IOException { + try { + // make download request + var response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() != 200) { + throw new IOException("Failed to download update, status code: " + response.statusCode()); + } + + // update totalBytes + response.headers().firstValueAsLong("Content-Length").ifPresent(totalBytes::set); + + // prepare checksum calculation + MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); // Initialize SHA-256 digest, not used here but can be extended for checksum validation + } catch (NoSuchAlgorithmException e) { + throw new AssertionError("Every implementation of the Java platform is required to support [...] SHA-256", e); + } + + // write bytes to file + try (var in = new DownloadInputStream(response.body(), loadedBytes, sha256); + var src = Channels.newChannel(in); + var dst = FileChannel.open(destination, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)) { + dst.transferFrom(src, 0, Long.MAX_VALUE); + } + + // verify checksum if provided + byte[] calculatedChecksum = sha256.digest(); + if (checksum != null && !MessageDigest.isEqual(calculatedChecksum, checksum)) { + throw new IOException("Checksum verification failed for downloaded file: " + destination); + } + } catch (InterruptedException e) { + throw new InterruptedIOException("Download interrupted"); + } + } + + /** + * An InputStream decorator that counts the number of bytes read and updates a MessageDigest for checksum calculation. + */ + private static class DownloadInputStream extends FilterInputStream { + + private final LongAdder counter; + private final MessageDigest digest; + + protected DownloadInputStream(InputStream in, LongAdder counter, MessageDigest digest) { + super(in); + this.counter = counter; + this.digest = digest; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = super.read(b, off, len); + if (n != -1) { + digest.update(b, off, n); + counter.add(n); + } + return n; + } + + @Override + public int read() throws IOException { + int b = super.read(); + if (b != -1) { + digest.update((byte) b); + counter.increment(); + } + return b; + } + + } + +} diff --git a/src/main/java/org/cryptomator/integrations/update/NoopUpdateStep.java b/src/main/java/org/cryptomator/integrations/update/NoopUpdateStep.java new file mode 100644 index 0000000..8638588 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/NoopUpdateStep.java @@ -0,0 +1,30 @@ +package org.cryptomator.integrations.update; + +import java.util.concurrent.TimeUnit; + +record NoopUpdateStep(String description) implements UpdateStep { + + @Override + public void start() {} + + @Override + public double preparationProgress() { + return -1.0; + } + + @Override + public void cancel() {} + + @Override + public void await() {} + + @Override + public boolean await(long timeout, TimeUnit unit) { + return true; // always done + } + + @Override + public UpdateStep nextStep() { + return null; + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/SemVerComparator.java b/src/main/java/org/cryptomator/integrations/update/SemVerComparator.java new file mode 100644 index 0000000..aff1ca8 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/SemVerComparator.java @@ -0,0 +1,84 @@ +package org.cryptomator.integrations.update; + +import java.util.Comparator; +import java.util.regex.Pattern; + +/** + * Compares version strings according to SemVer 2.0.0. + */ +public class SemVerComparator implements Comparator { + + public static final SemVerComparator INSTANCE = new SemVerComparator(); + + private static final Pattern VERSION_SEP = Pattern.compile("\\."); // http://semver.org/spec/v2.0.0.html#spec-item-2 + private static final String PRE_RELEASE_SEP = "-"; // http://semver.org/spec/v2.0.0.html#spec-item-9 + private static final String BUILD_SEP = "+"; // http://semver.org/spec/v2.0.0.html#spec-item-10 + + @Override + public int compare(String version1, String version2) { + // "Build metadata SHOULD be ignored when determining version precedence. + // Thus, two versions that differ only in the build metadata, have the same precedence." + String trimmedV1 = substringBefore(version1, BUILD_SEP); + String trimmedV2 = substringBefore(version2, BUILD_SEP); + + if (trimmedV1.equals(trimmedV2)) { + return 0; + } + + String v1MajorMinorPatch = substringBefore(trimmedV1, PRE_RELEASE_SEP); + String v2MajorMinorPatch = substringBefore(trimmedV2, PRE_RELEASE_SEP); + String v1PreReleaseVersion = substringAfter(trimmedV1, PRE_RELEASE_SEP); + String v2PreReleaseVersion = substringAfter(trimmedV2, PRE_RELEASE_SEP); + return compare(v1MajorMinorPatch, v1PreReleaseVersion, v2MajorMinorPatch, v2PreReleaseVersion); + } + + private static int compare(String v1MajorMinorPatch, String v1PreReleaseVersion, String v2MajorMinorPatch, String v2PreReleaseVersion) { + int comparisonResult = compareNumericallyThenLexicographically(v1MajorMinorPatch, v2MajorMinorPatch); + if (comparisonResult == 0) { + if (v1PreReleaseVersion.isEmpty()) { + return 1; // 1.0.0 > 1.0.0-BETA + } else if (v2PreReleaseVersion.isEmpty()) { + return -1; // 1.0.0-BETA < 1.0.0 + } else { + return compareNumericallyThenLexicographically(v1PreReleaseVersion, v2PreReleaseVersion); + } + } else { + return comparisonResult; + } + } + + private static int compareNumericallyThenLexicographically(String version1, String version2) { + final String[] vComps1 = VERSION_SEP.split(version1); + final String[] vComps2 = VERSION_SEP.split(version2); + final int commonCompCount = Math.min(vComps1.length, vComps2.length); + + for (int i = 0; i < commonCompCount; i++) { + int subversionComparisonResult; + try { + final int v1 = Integer.parseInt(vComps1[i]); + final int v2 = Integer.parseInt(vComps2[i]); + subversionComparisonResult = v1 - v2; + } catch (NumberFormatException ex) { + // ok, lets compare this fragment lexicographically + subversionComparisonResult = vComps1[i].compareTo(vComps2[i]); + } + if (subversionComparisonResult != 0) { + return subversionComparisonResult; + } + } + + // all in common so far? longest version string is considered the higher version: + return vComps1.length - vComps2.length; + } + + private static String substringBefore(String str, String separator) { + int index = str.indexOf(separator); + return index == -1 ? str : str.substring(0, index); + } + + private static String substringAfter(String str, String separator) { + int index = str.indexOf(separator); + return index == -1 ? "" : str.substring(index + separator.length()); + } + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java b/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java new file mode 100644 index 0000000..8d4d582 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java @@ -0,0 +1,17 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.ApiStatus; + +import java.io.IOException; + +@ApiStatus.Experimental +public class UpdateFailedException extends IOException { + + public UpdateFailedException(String message) { + super(message); + } + + public UpdateFailedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java b/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java new file mode 100644 index 0000000..b0dec37 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java @@ -0,0 +1,27 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.NotNull; + +public interface UpdateInfo> { + + /** + * @return The version string of the available update. + */ + String version(); + + /** + * @return The update mechanism that provided this update info. + */ + UpdateMechanism updateMechanism(); + + /** + * Typesafe equivalent to {@code updateMechanism().firstStep(this)}. + * @return Result of {@link UpdateMechanism#firstStep(UpdateInfo)}. + * @throws UpdateFailedException If no update process can be started, e.g. due to network or I/O issues. + */ + @NotNull + default UpdateStep useToPrepareFirstStep() throws UpdateFailedException { + @SuppressWarnings("unchecked") T self = (T) this; + return updateMechanism().firstStep(self); + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java new file mode 100644 index 0000000..cd95805 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java @@ -0,0 +1,58 @@ +package org.cryptomator.integrations.update; + +import org.cryptomator.integrations.common.IntegrationsLoader; +import org.cryptomator.integrations.common.NamedServiceProvider; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.http.HttpClient; +import java.util.Optional; + +@ApiStatus.Experimental +public interface UpdateMechanism> extends NamedServiceProvider { + + String UPDATE_MECHANISM_PROPERTY = "cryptomator.updateMechanism"; + + @SuppressWarnings("rawtypes") + static Optional get() { + return Optional.ofNullable(System.getProperty(UPDATE_MECHANISM_PROPERTY)) + .flatMap(name -> IntegrationsLoader.loadSpecific(UpdateMechanism.class, name)); + } + + /** + * Checks whether an update is available by comparing the given version strings. + * @param updateVersion The version string of the update, e.g. "1.2.3". + * @param installedVersion The version string of the currently installed application, e.g. "1.2.3-beta4". + * @return true if an update is available, false otherwise. Always true for SNAPSHOT versions. + */ + static boolean isUpdateAvailable(String updateVersion, String installedVersion) { + if (installedVersion.contains("SNAPSHOT")) { + return true; // SNAPSHOT versions are always considered to be outdated. + } else { + return SemVerComparator.INSTANCE.compare(updateVersion, installedVersion) > 0; + } + } + + /** + * Checks whether an update is available. + * @param currentVersion The full version string of the currently installed application, e.g. "1.2.3-beta4". + * @param httpClient An HTTP client that can be used to check for updates. + * @return An {@link UpdateInfo} if an update is available, or null otherwise. + * @throws UpdateFailedException If the availability of an update could not be determined + */ + @Blocking + @Nullable + T checkForUpdate(String currentVersion, HttpClient httpClient) throws UpdateFailedException; + + /** + * Returns the first step to prepare the update. This can be anything like downloading the update, checking signatures, etc. + * @param updateInfo The {@link UpdateInfo} representing the update to be prepared. + * @return a new {@link UpdateStep} that can be used to monitor the progress of the update preparation. The task will complete when the preparation is done. + * @throws UpdateFailedException If no update process can be started, e.g. due to network or I/O issues. + */ + @NotNull + UpdateStep firstStep(T updateInfo) throws UpdateFailedException; + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java new file mode 100644 index 0000000..c413bdf --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java @@ -0,0 +1,110 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Range; + +import java.io.IOException; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@ApiStatus.Experimental +public interface UpdateStep { + + /** + * A magic constant indicating that the application shall terminate. + *

+ * This step can be returned as the last step of the update process, usually immediately after a restart has been scheduled. + */ + UpdateStep EXIT = new NoopUpdateStep("Exiting..."); + + /** + * A magic constant indicating that the update process shall be retried. + */ + UpdateStep RETRY = new NoopUpdateStep("Retry"); + + + static UpdateStep of(String name, Callable nextStep) { + return new UpdateStepAdapter() { + + @Override + public UpdateStep call() throws Exception { + return nextStep.call(); + } + + @Override + public String description() { + return name; + } + }; + } + + /** + * A short description of this update step. + * @return a human-readable description of this update step. + */ + String description(); + + /** + * Starts work on this update step in a non-blocking manner. + * @throws IllegalThreadStateException if this step has already been started. + */ + @NonBlocking + void start() throws IllegalThreadStateException; + + /** + * A thread-safe method to check the progress of the update preparation. + * @return a value between 0.0 and 1.0 indicating the progress of the update preparation or -1.0 indicating indeterminate progress. + */ + double preparationProgress(); + + /** + * Cancels this update step and cleans up any temporary resources. + */ + void cancel(); + + /** + * Blocks the current thread until this update step completed or an error occured. + *

+ * If the step is already complete, this method returns immediately. + * + * @throws InterruptedException if the current thread is interrupted while waiting. + */ + void await() throws InterruptedException; + + /** + * Blocks the current thread until this update step completed or an error occured, or until the specified timeout expires. + *

+ * If the step is already complete, this method returns immediately. + * + * @param timeout the maximum time to wait + * @param unit the time unit of the {@code timeout} argument + * @return true if the update is prepared + */ + boolean await(long timeout, TimeUnit unit) throws InterruptedException; + + default boolean isDone() { + try { + return await(0, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + /** + * Once the update preparation is complete, this method can be called to launch the external update process. + *

+ * This method shall be called after making sure that the application is ready to be restarted, e.g. after locking all vaults. + * + * @return the next {@link UpdateStep step} of the update process or null if this was the final step. + * @throws IllegalStateException if the update preparation is not complete or if the update process cannot be launched. + * @throws IOException if the update preparation failed + * @implSpec The returned {@link UpdateStep} must either be stateless or a new instance must be returned on each call. + */ + @Nullable + UpdateStep nextStep() throws IllegalStateException, IOException; + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateStepAdapter.java b/src/main/java/org/cryptomator/integrations/update/UpdateStepAdapter.java new file mode 100644 index 0000000..dc0cd0d --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateStepAdapter.java @@ -0,0 +1,65 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; + +public abstract class UpdateStepAdapter implements Callable, Runnable, UpdateStep { + + protected final Thread thread; + protected volatile UpdateStep result; + protected volatile Exception exception; + + public UpdateStepAdapter() { + this.thread = Thread.ofVirtual().name("UpdateStep", 0).unstarted(this); + } + + @Override + public final void run() { + try { + this.result = this.call(); + } catch (Exception e) { + this.exception = e; + } + } + + @Override + public void start() throws IllegalThreadStateException { + thread.start(); + } + + @Override + public double preparationProgress() { + return -1.0; + } + + @Override + public void cancel() { + thread.interrupt(); + } + + @Override + public void await() throws InterruptedException { + thread.join(); + } + + @Override + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return thread.join(Duration.of(timeout, unit.toChronoUnit())); + } + + @Override + public @Nullable UpdateStep nextStep() throws IllegalStateException, IOException { + if (!isDone()) { + throw new IllegalStateException("Update step not completed yet"); + } + return switch (exception) { + case null -> result; + case IOException e -> throw e; + default -> throw new IOException("Update step failed", exception); + }; + } +} diff --git a/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java b/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java new file mode 100644 index 0000000..ac080d8 --- /dev/null +++ b/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java @@ -0,0 +1,79 @@ +package org.cryptomator.integrations.update; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.Comparator; + +public class SemVerComparatorTest { + + private final Comparator semVerComparator = SemVerComparator.INSTANCE; + + // equal versions + + @ParameterizedTest + @CsvSource({ + "1.23.4, 1.23.4", + "1.23.4-alpha, 1.23.4-alpha", + "1.23.4+20170101, 1.23.4+20171231", + "1.23.4-alpha+20170101, 1.23.4-alpha+20171231" + }) + public void compareEqualVersions(String left, String right) { + Assertions.assertEquals(0, Integer.signum(semVerComparator.compare(left, right))); + } + + // newer versions in first argument + + @ParameterizedTest + @CsvSource({ + "1.23.5, 1.23.4", + "1.24.4, 1.23.4", + "1.23.4, 1.23", + "1.23.4, 1.23.4-SNAPSHOT", + "1.23.4, 1.23.4-56.78", + "1.23.4-beta, 1.23.4-alpha", + "1.23.4-alpha.1, 1.23.4-alpha", + "1.23.4-56.79, 1.23.4-56.78", + "1.23.4-alpha, 1.23.4-1", + }) + public void compareHigherToLowerVersions(String higherVersion, String lowerVersion) { + Assertions.assertEquals(1, Integer.signum(semVerComparator.compare(higherVersion, lowerVersion))); + } + + // newer versions in second argument + + @ParameterizedTest + @CsvSource({ + "1.23.4, 1.23.5", + "1.23.4, 1.24.4", + "1.23, 1.23.4", + "1.23.4-SNAPSHOT, 1.23.4", + "1.23.4-56.78, 1.23.4", + "1.23.4-alpha, 1.23.4-beta", + "1.23.4-alpha, 1.23.4-alpha.1", + "1.23.4-56.78, 1.23.4-56.79", + "1.23.4-1, 1.23.4-alpha", + }) + public void compareLowerToHigherVersions(String lowerVersion, String higherVersion) { + Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare(lowerVersion, higherVersion))); + } + + // test vector from https://semver.org/spec/v2.0.0.html#spec-item-11: + // Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0. + @ParameterizedTest + @CsvSource({ + "1.0.0-alpha, 1.0.0-alpha.1", + "1.0.0-alpha.1, 1.0.0-alpha.beta", + "1.0.0-alpha.beta, 1.0.0-beta", + "1.0.0-beta, 1.0.0-beta.2", + "1.0.0-beta.2, 1.0.0-beta.11", + "1.0.0-beta.11, 1.0.0-rc.1", + "1.0.0-rc.1, 1.0.0" + }) + public void testPrecedenceSpec(String left, String right) { + Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare(left, right))); + } + +}