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/update/DownloadUpdateProcess.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java new file mode 100644 index 0000000..6c3d15c --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java @@ -0,0 +1,185 @@ +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 DownloadUpdateProcess implements UpdateProcess { + + protected final Path workDir; + private final String downloadFileName; + private final URI uri; + 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 workDir A temporary directory where to download the update file. + * @param downloadFileName The name of the file to which the update will be downloaded + * @param uri The URI from which the update will be downloaded. + * @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 DownloadUpdateProcess(Path workDir, String downloadFileName, URI uri, byte[] checksum, long estDownloadSize) { + this.workDir = workDir; + this.downloadFileName = downloadFileName; + this.uri = uri; + this.checksum = checksum; + this.totalBytes = new AtomicLong(estDownloadSize); + this.downloadThread = Thread.ofVirtual().unstarted(this::download); + } + + protected void startDownload() { + 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); + } + + protected boolean isDone() { + try { + return downloadCompleted.await(0, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public void cancel() { + downloadThread.interrupt(); + } + + private void download() { + try { + download(workDir.resolve(downloadFileName)); + } catch (IOException e) { + // TODO: eventually handle this via structured concurrency? + downloadException = e; + } finally { + downloadCompleted.countDown(); + } + } + + /** + * Downloads the update from the given URI and saves it to the specified filename in the working directory. + * @param downloadPath The path to where to save the downloaded file. + * @throws IOException indicating I/O errors during the download or file writing process or due to checksum mismatch + */ + protected void download(Path downloadPath) throws IOException { + var request = HttpRequest.newBuilder().uri(uri).GET().build(); + try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build()) { // TODO: make http client injectable? + // 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(downloadPath, 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: " + downloadPath); + } + + // post-download processing + postDownload(downloadPath); + } catch (InterruptedException e) { + throw new InterruptedIOException("Download interrupted"); + } + } + + protected void postDownload(Path downloadPath) throws IOException { + // Default implementation does nothing, can be overridden by subclasses for specific post-download actions + } + + /** + * 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/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/UpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java new file mode 100644 index 0000000..2630adf --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java @@ -0,0 +1,46 @@ +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; + +@ApiStatus.Experimental +public interface UpdateMechanism extends NamedServiceProvider { + + static UpdateMechanism get() { + // TODO: load preferred udpate mechanism, if specified in system properties. + return IntegrationsLoader.load(UpdateMechanism.class).orElseThrow(); // Fallback "show download page" mechanism always available. + } + + /** + * 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". + * @return true if an update is available, false otherwise. + * @throws UpdateFailedException If the availability of an update could not be determined + */ + @Blocking + boolean isUpdateAvailable(String currentVersion) throws UpdateFailedException; + + /** + * Performs as much as possible to prepare the update. This may include downloading the update, checking signatures, etc. + * @return a new {@link UpdateProcess} 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. + */ + UpdateProcess prepareUpdate() throws UpdateFailedException; + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java b/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java new file mode 100644 index 0000000..1b5811a --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java @@ -0,0 +1,54 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.ApiStatus; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +@ApiStatus.Experimental +public interface UpdateProcess { + + /** + * 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 the update process and cleans up any resources that were used during the preparation. + */ + void cancel(); + + /** + * Blocks the current thread until the update preparation is complete or an error occurs. + *

+ * If the preparation 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 the update preparation is complete or an error occurs, or until the specified timeout expires. + *

+ * If the preparation 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; + + /** + * 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 a {@link ProcessHandle} that represents the external update process. + * @throws IllegalStateException if the update preparation is not complete or if the update process cannot be launched. + * @throws IOException if the update preparation failed or starting the update process failed + */ + ProcessHandle applyUpdate() throws IllegalStateException, IOException; + + +} \ No newline at end of file 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))); + } + +}