-
Notifications
You must be signed in to change notification settings - Fork 4
External update mechanism #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
88f7231
a501a2b
6af0aa8
a2a6f98
f3f3c35
5f29005
28680db
eaa63e5
84076df
e875adb
dde78a1
0f765d6
5dadcbe
61de9f3
ffc3666
2493753
b0d9fe4
41d8e4c
a052dd0
5856f28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| } 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; | ||
| } | ||
| } | ||
overheadhunter marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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; | ||
overheadhunter marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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(); }