Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

<!-- Test dependencies -->
<junit.version>5.13.4</junit.version>
<mockito.version>5.19.0</mockito.version>
<mockito.version>5.20.0</mockito.version>

<!-- Build dependencies -->
<mvn-compiler.version>3.14.0</mvn-compiler.version>
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -29,4 +32,5 @@
uses TrayMenuController;
uses UiAppearanceProvider;
uses QuickAccessService;
uses UpdateMechanism;
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ public static <T> Optional<T> load(Class<T> 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 <T> Type of the service
*/
public static <T> Optional<T> loadSpecific(Class<T> clazz, String implementationClassName) {
return ServiceLoader.load(clazz, ClassLoaderFactory.forPluginDir()).stream()
.filter(provider -> provider.type().getName().equals(implementationClassName))
.map(ServiceLoader.Provider::get)
.findAny();
}
Comment on lines +46 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

loadSpecific bypasses OS/availability checks and can throw at instantiation

This maps Provider::get directly, skipping isSupportedOperatingSystem, passesStaticAvailabilityCheck, instantiateServiceProvider (error handling), and passesInstanceAvailabilityCheck. A mismatched provider may be instantiated or ServiceConfigurationError may surface to callers.

Apply this diff to reuse the vetted pipeline and add null checks:

 public static <T> Optional<T> loadSpecific(Class<T> clazz, String implementationClassName) {
-    return ServiceLoader.load(clazz, ClassLoaderFactory.forPluginDir()).stream()
-            .filter(provider -> provider.type().getName().equals(implementationClassName))
-            .map(ServiceLoader.Provider::get)
-            .findAny();
+    Objects.requireNonNull(clazz, "Service to load not specified.");
+    Objects.requireNonNull(implementationClassName, "Implementation class name not specified.");
+    var sl = ServiceLoader.load(clazz, ClassLoaderFactory.forPluginDir());
+    return loadAll(sl, clazz)
+            .filter(impl -> impl.getClass().getName().equals(implementationClassName))
+            .findAny();
 }

Committable suggestion skipped: line range outside the PR's diff.


/**
* Loads all suited service providers ordered by priority in descending order.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +123 to +134
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent leftover partial files; write to temp and atomically move after checksum verification

Failures (I/O, checksum mismatch) currently leave a partial file at destination. Write to a “.part” file, delete on failure, and move atomically on success. Also permit overwrite behavior to be explicit.

- 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);
- }
+ var tmp = destination.resolveSibling(destination.getFileName() + ".part");
+ try (var in = new DownloadInputStream(response.body(), loadedBytes, sha256);
+      var src = Channels.newChannel(in);
+      var dst = FileChannel.open(tmp, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
+   dst.transferFrom(src, 0, Long.MAX_VALUE);
+ } catch (IOException ioe) {
+   try { java.nio.file.Files.deleteIfExists(tmp); } catch (IOException ignore) {}
+   throw ioe;
+ }
 ...
- if (checksum != null && !MessageDigest.isEqual(calculatedChecksum, checksum)) {
-   throw new IOException("Checksum verification failed for downloaded file: " + destination);
- }
+ if (checksum != null && !MessageDigest.isEqual(calculatedChecksum, checksum)) {
+   try { java.nio.file.Files.deleteIfExists(tmp); } catch (IOException ignore) {}
+   throw new IOException("Checksum verification failed for downloaded file: " + destination);
+ }
+ // move into place after successful verification
+ java.nio.file.Files.move(tmp, destination, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING);

Also applies to: 126-128, 130-135

🤖 Prompt for AI Agents
In src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java
around lines 123-134, avoid leaving partial files by writing download output to
a temporary ".part" file (e.g., destination + ".part") using CREATE_NEW, verify
checksum against the temp file's stream, and on successful verification
atomically move/rename the temp file to the final destination (use Files.move
with ATOMIC_MOVE and allow an explicit overwrite flag to control whether
REPLACE_EXISTING is used). Ensure any exception or checksum mismatch deletes the
temp file in a finally block so no leftover partial remains, and log or
propagate errors consistently.

} 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;
}

}

}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package org.cryptomator.integrations.update;

import java.util.Comparator;
import java.util.regex.Pattern;

/**
* Compares version strings according to <a href="http://semver.org/spec/v2.0.0.html">SemVer 2.0.0</a>.
*/
public class SemVerComparator implements Comparator<String> {

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());
}

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading