diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index a0e384f..e65cf04 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -6,6 +6,7 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index fc01513..732146c 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -8,5 +8,5 @@
-
+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index f47a06c..44f1098 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
0.2.1-SNAPSHOT
- 11
+ 17
${project.basedir}/testkeystore.jks
testkey
@@ -45,11 +45,12 @@
-
+
org.apache.maven.plugins
maven-release-plugin
3.1.1
+
org.apache.maven.plugins
maven-jarsigner-plugin
diff --git a/src/main/java/eu/m724/jarupdater/download/DownloadProgress.java b/src/main/java/eu/m724/jarupdater/download/DownloadProgress.java
new file mode 100644
index 0000000..bd38a0c
--- /dev/null
+++ b/src/main/java/eu/m724/jarupdater/download/DownloadProgress.java
@@ -0,0 +1,21 @@
+package eu.m724.jarupdater.download;
+
+/**
+ * Represents download progress in a point of time
+ *
+ * @param downloadedBytes The number of bytes already downloaded
+ * @param fileSizeBytes The number of bytes the downloaded file has. -1 if unknown
+ */
+public record DownloadProgress(
+ long downloadedBytes,
+ long fileSizeBytes
+) {
+ /**
+ * Creates a new DownloadProgress with the downloaded value incremented by read
bytes.
+ * @param read The bytes now read
+ * @return The new DownloadProgress
+ */
+ public DownloadProgress plus(int read) {
+ return new DownloadProgress(downloadedBytes + read, fileSizeBytes);
+ }
+}
diff --git a/src/main/java/eu/m724/jarupdater/download/Downloader.java b/src/main/java/eu/m724/jarupdater/download/Downloader.java
index abdfa67..2fc0f8f 100644
--- a/src/main/java/eu/m724/jarupdater/download/Downloader.java
+++ b/src/main/java/eu/m724/jarupdater/download/Downloader.java
@@ -1,22 +1,14 @@
package eu.m724.jarupdater.download;
+import java.net.URI;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
public interface Downloader {
- /**
- * Downloads a file and verifies its checksum
- * @param url The file URL
- * @param sha256hex The expected SHA-256 hash (in hex)
- * @return A future which returns the saved file. It can throw {@link java.io.IOException} and {@link java.security.SignatureException}
- */
- CompletableFuture downloadAndVerify(String url, String sha256hex);
-
- /**
- * Moves source into destination
- * @param source The source file
- * @param destination The destination file (not folder)
- * @return A future which can throw {@link java.io.IOException}
- */
- CompletableFuture install(Path source, Path destination);
+ CompletableFuture downloadAndVerify(URI uri, byte[] expectedHash, Consumer progressConsumer);
+
+ default CompletableFuture downloadAndVerify(URI uri, byte[] expectedHash) {
+ return downloadAndVerify(uri, expectedHash, progress -> {});
+ }
}
diff --git a/src/main/java/eu/m724/jarupdater/download/HTTPException.java b/src/main/java/eu/m724/jarupdater/download/HTTPException.java
new file mode 100644
index 0000000..e96f038
--- /dev/null
+++ b/src/main/java/eu/m724/jarupdater/download/HTTPException.java
@@ -0,0 +1,19 @@
+package eu.m724.jarupdater.download;
+
+import java.net.http.HttpResponse;
+
+/**
+ * An exception holding a {@link HttpResponse}, thrown when an HTTP request completed unsuccessfully.
+ */
+public class HTTPException extends Exception {
+ private final HttpResponse> response;
+
+ public HTTPException(String message, HttpResponse> response) {
+ super(message);
+ this.response = response;
+ }
+
+ public HttpResponse> getResponse() {
+ return response;
+ }
+}
diff --git a/src/main/java/eu/m724/jarupdater/download/HashMismatchException.java b/src/main/java/eu/m724/jarupdater/download/HashMismatchException.java
new file mode 100644
index 0000000..6241009
--- /dev/null
+++ b/src/main/java/eu/m724/jarupdater/download/HashMismatchException.java
@@ -0,0 +1,54 @@
+package eu.m724.jarupdater.download;
+
+import java.util.HexFormat;
+
+/**
+ * Thrown when the file couldn't be verified due to unexpected fileHash.
+ */
+public class HashMismatchException extends Exception {
+ private final byte[] expectedHash;
+ private final byte[] actualHash;
+
+ public HashMismatchException(byte[] expectedHash, byte[] actualHash) {
+ super("Expected fileHash != Actual fileHash");
+
+ this.expectedHash = expectedHash;
+ this.actualHash = actualHash;
+ }
+
+ /**
+ * Get the expected SHA-256 fileHash as a byte array.
+ *
+ * @return The expected SHA-256 fileHash as a byte array.
+ */
+ public byte[] getExpectedHash() {
+ return expectedHash;
+ }
+
+ /**
+ * Get the actual SHA-256 fileHash as a byte array.
+ *
+ * @return The actual SHA-256 fileHash as a byte array.
+ */
+ public byte[] getActualHash() {
+ return actualHash;
+ }
+
+ /**
+ * Get the expected SHA-256 fileHash in a String hex representation.
+ *
+ * @return The expected SHA-256 fileHash in a String hex representation.
+ */
+ public String getExpectedHashDecoded() {
+ return HexFormat.of().formatHex(expectedHash);
+ }
+
+ /**
+ * Get the actual SHA-256 fileHash in a String hex representation.
+ *
+ * @return The actual SHA-256 fileHash in a String hex representation.
+ */
+ public String getActualHashDecoded() {
+ return HexFormat.of().formatHex(actualHash);
+ }
+}
diff --git a/src/main/java/eu/m724/jarupdater/download/SimpleDownloader.java b/src/main/java/eu/m724/jarupdater/download/SimpleDownloader.java
deleted file mode 100644
index 933157a..0000000
--- a/src/main/java/eu/m724/jarupdater/download/SimpleDownloader.java
+++ /dev/null
@@ -1,93 +0,0 @@
-package eu.m724.jarupdater.download;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.ProxySelector;
-import java.net.URI;
-import java.net.http.HttpClient;
-import java.net.http.HttpClient.Redirect;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
-import java.net.http.HttpResponse.BodyHandlers;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.security.SignatureException;
-import java.util.Arrays;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionException;
-
-public class SimpleDownloader implements Downloader {
- private final String branding;
-
- public SimpleDownloader(String branding) {
- this.branding = branding;
- }
-
- @Override
- public CompletableFuture downloadAndVerify(String url, String sha256hex) { // TODO progress?
-
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(url))
- .header("User-Agent", "ju/1") // jar updater v1
- .build();
-
- CompletableFuture> responseFuture =
- HttpClient.newBuilder()
- .followRedirects(Redirect.NORMAL)
- .proxy(ProxySelector.getDefault()).build().
- sendAsync(request, BodyHandlers.ofInputStream());
-
-
- return responseFuture.thenApply(response -> {
-
- try (InputStream bodyStream = response.body()) {
-
- Path tempFile = Files.createTempFile(branding, null);
- MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
-
- try (OutputStream tempFileOutputStream = Files.newOutputStream(tempFile)) {
- while (bodyStream.available() > 0) {
- byte[] bytes = bodyStream.readAllBytes();
-
- messageDigest.update(bytes);
- tempFileOutputStream.write(bytes);
- }
- }
-
- if (!Arrays.equals(messageDigest.digest(), hexStringToByteArray(sha256hex)))
- throw new SignatureException(); // This is not outside try because impact is too small to justify more code
-
- return tempFile;
-
- } catch (IOException | NoSuchAlgorithmException | SignatureException e) {
- throw new CompletionException(e);
- }
-
- });
- }
-
- @Override
- public CompletableFuture install(Path source, Path destination) {
- return CompletableFuture.runAsync(() -> {
- try {
- Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING);
- } catch (IOException e) {
- throw new CompletionException(e);
- }
- });
- }
-
- private static byte[] hexStringToByteArray(String s) {
- int len = s.length();
- byte[] data = new byte[len / 2];
- for (int i = 0; i < len; i += 2) {
- data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
- + Character.digit(s.charAt(i + 1), 16));
- }
- return data;
- }
-}
diff --git a/src/main/java/eu/m724/jarupdater/download/SimpleHTTPDownloader.java b/src/main/java/eu/m724/jarupdater/download/SimpleHTTPDownloader.java
new file mode 100644
index 0000000..58eff2c
--- /dev/null
+++ b/src/main/java/eu/m724/jarupdater/download/SimpleHTTPDownloader.java
@@ -0,0 +1,234 @@
+package eu.m724.jarupdater.download;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.function.Consumer;
+
+/**
+ * A {@link Downloader} that downloads by HTTP.
+ */
+public class SimpleHTTPDownloader implements Downloader {
+ private final String branding;
+ private final HttpClient httpClient;
+ private final int bufferSizeBytes;
+ private final String hashingAlgorithm;
+
+ private SimpleHTTPDownloader(String branding, HttpClient httpClient, int bufferSizeBytes, String hashingAlgorithm) {
+ this.branding = branding;
+ this.httpClient = httpClient;
+ this.bufferSizeBytes = bufferSizeBytes;
+ this.hashingAlgorithm = hashingAlgorithm;
+ }
+
+ /**
+ * Downloads a file from the specified URI and verifies it.
+ * The future can throw:
+ *
+ * - {@link IOException} - multiple possible reasons
+ * - {@link HashMismatchException} - when the fileHash of the downloaded file doesn't match the expected fileHash
+ * - {@link InterruptedException} - when the download is interrupted
+ * - {@link HTTPException} - when the response's status code is not 200
+ *
+ *
+ * @param uri The file URI
+ * @param expectedHash The expected SHA-256 fileHash
+ * @param progressConsumer A consumer, which consumes download progress
+ * @return A future
+ */
+ @Override
+ public CompletableFuture downloadAndVerify(URI uri, byte[] expectedHash, Consumer progressConsumer) {
+ HttpRequest request = HttpRequest.newBuilder(uri)
+ .header("User-Agent", "jarupdater/1.0 (%s)".formatted(branding))
+ .build();
+
+ CompletableFuture> responseFuture =
+ httpClient.sendAsync(request, BodyHandlers.ofInputStream());
+
+ return responseFuture.thenApply(response -> {
+ Path tempFile = null;
+
+ try {
+ if (response.statusCode() != 200) {
+ throw new HTTPException("Unexpected status code: " + response.statusCode(), response);
+ }
+
+ tempFile = Files.createTempFile(branding, ".jar.download");
+ MessageDigest messageDigest = MessageDigest.getInstance(hashingAlgorithm);
+
+ long fileSizeBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1);
+ DownloadProgress downloadProgress = new DownloadProgress(0, fileSizeBytes);
+
+ byte[] buffer = new byte[bufferSizeBytes];
+
+ try (
+ InputStream bodyStream = response.body();
+ OutputStream tempFileOutputStream = Files.newOutputStream(tempFile);
+ ) {
+ int bytesRead;
+ while ((bytesRead = bodyStream.read(buffer)) != -1) {
+ messageDigest.update(buffer, 0, bytesRead);
+ tempFileOutputStream.write(buffer, 0, bytesRead);
+
+ if (progressConsumer != null) {
+ downloadProgress = downloadProgress.plus(bytesRead);
+ progressConsumer.accept(downloadProgress);
+ }
+
+ if (Thread.currentThread().isInterrupted()) {
+ throw new InterruptedException("Interrupted while downloading");
+ }
+ }
+ }
+
+ byte[] fileHash = messageDigest.digest();
+ if (!Arrays.equals(expectedHash, fileHash)) {
+ throw new HashMismatchException(expectedHash, fileHash);
+ }
+ } catch (IOException | NoSuchAlgorithmException | HashMismatchException | InterruptedException | HTTPException e) {
+ if (tempFile != null) {
+ try {
+ Files.deleteIfExists(tempFile);
+ } catch (IOException ex) {
+ e.addSuppressed(ex);
+ }
+ }
+
+ throw new CompletionException(e);
+ }
+
+ return tempFile;
+ });
+ }
+ /**
+ * Downloads a file from the specified URI and verifies it.
+ * The future can throw:
+ *
+ * - {@link IOException} - multiple possible reasons
+ * - {@link HashMismatchException} - when the fileHash of the downloaded file doesn't match the expected fileHash
+ * - {@link InterruptedException} - when the download is interrupted
+ * - {@link HTTPException} - when the response's status code is not 200
+ *
+ *
+ * @param uri The file URI
+ * @param expectedHash The expected SHA-256 fileHash
+ * @return A future
+ */
+ @Override
+ public CompletableFuture downloadAndVerify(URI uri, byte[] expectedHash) {
+ // just for a tiny bit of performance over the default
+ return downloadAndVerify(uri, expectedHash, null);
+ }
+
+ /**
+ * A builder class that builds {@link SimpleHTTPDownloader} instances.
+ */
+ public static class Builder {
+ private String branding;
+ private HttpClient httpClient;
+ private Integer bufferSize;
+ private String hashingAlgorithm;
+
+ /**
+ * Sets the branding of the {@link SimpleHTTPDownloader}.
+ * Branding is used for the temporary file and the HTTP user agent.
+ * The default is a generic jarupdater
.
+ *
+ * @param branding The branding
+ * @return This builder instance
+ */
+ public Builder withBranding(String branding) {
+ if (branding.length() > 100) {
+ throw new IllegalArgumentException("Branding must be at most 100 characters long");
+ }
+
+ if (branding.isBlank()) {
+ throw new IllegalArgumentException("Branding mustn't be blank");
+ }
+
+ this.branding = branding;
+ return this;
+ }
+
+ /**
+ * Sets the {@link HttpClient} instance used in the {@link SimpleHTTPDownloader}.
+ *
+ * @param httpClient The {@link HttpClient}
+ * @return This builder instance
+ */
+ public Builder withHttpClient(HttpClient httpClient) {
+ this.httpClient = httpClient;
+ return this;
+ }
+
+ /**
+ * Sets the buffer size used while downloading using the {@link SimpleHTTPDownloader}.
+ *
+ * @param bufferSize The buffer size in bytes
+ * @return This builder instance
+ */
+ public Builder withBufferSize(int bufferSize) {
+ if (bufferSize <= 0) {
+ throw new IllegalArgumentException("Buffer size must be larger than 0");
+ }
+
+ this.bufferSize = bufferSize;
+ return this;
+ }
+
+ /**
+ * Sets the hashing algorithm used to verify files downloaded with {@link SimpleHTTPDownloader}.
+ * For more information, see {@link MessageDigest#getInstance(String)}
+ *
+ * @param hashingAlgorithm The hashing algorithm
+ * @return This builder instance
+ */
+ public Builder withHashingAlgorithm(String hashingAlgorithm) {
+ try {
+ MessageDigest.getInstance(hashingAlgorithm);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalArgumentException(e);
+ }
+
+ this.hashingAlgorithm = hashingAlgorithm;
+ return this;
+ }
+
+ /**
+ * Builds a new {@link SimpleHTTPDownloader} instance using this builder's settings.
+ *
+ * @return The new {@link SimpleHTTPDownloader} instance using this builder's settings.
+ */
+ public SimpleHTTPDownloader build() {
+ if (branding == null) this.branding = Defaults.DEFAULT_BRANDING;
+ if (bufferSize == null) this.bufferSize = Defaults.DEFAULT_BUFFER_SIZE;
+ if (hashingAlgorithm == null) this.hashingAlgorithm = Defaults.DEFAULT_HASHING_ALGORITHM;
+
+ if (httpClient == null) {
+ this.httpClient = HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+ }
+
+ return new SimpleHTTPDownloader(branding, httpClient, bufferSize, hashingAlgorithm);
+ }
+ }
+
+ private static class Defaults {
+ private static final String DEFAULT_BRANDING = "jarupdater";
+ private static final int DEFAULT_BUFFER_SIZE = 8192;
+ private static final String DEFAULT_HASHING_ALGORITHM = "SHA-256";
+ }
+}
diff --git a/src/main/java/eu/m724/jarupdater/environment/ConstantEnvironment.java b/src/main/java/eu/m724/jarupdater/environment/ConstantEnvironment.java
index b52cb0b..2e0fe81 100644
--- a/src/main/java/eu/m724/jarupdater/environment/ConstantEnvironment.java
+++ b/src/main/java/eu/m724/jarupdater/environment/ConstantEnvironment.java
@@ -2,19 +2,10 @@ package eu.m724.jarupdater.environment;
import java.nio.file.Path;
-public class ConstantEnvironment implements Environment {
- private final String runningVersion;
- private final String channel;
- private final Path runningJarFilePath;
-
- public ConstantEnvironment(String runningVersion, String channel, Path runningJarFilePath) {
- this.runningVersion = runningVersion;
- this.channel = channel;
- this.runningJarFilePath = runningJarFilePath;
- }
+public record ConstantEnvironment(
+ String runningVersion,
+ String updateChannel,
+ Path runningJarFilePath
+) implements Environment {
- @Override public String getRunningVersion() { return runningVersion; }
- @Override public String getChannel() { return channel; }
- @Override public Path getRunningJarFilePath() { return runningJarFilePath; }
-
}
diff --git a/src/main/java/eu/m724/jarupdater/environment/Environment.java b/src/main/java/eu/m724/jarupdater/environment/Environment.java
index a10a9f6..9bb58dd 100644
--- a/src/main/java/eu/m724/jarupdater/environment/Environment.java
+++ b/src/main/java/eu/m724/jarupdater/environment/Environment.java
@@ -5,19 +5,22 @@ import java.nio.file.Path;
public interface Environment {
/**
* Get the version of running software.
+ *
* @return The running software version
*/
- String getRunningVersion();
+ String runningVersion();
/**
* Get the configured update channel.
+ *
* @return The configured update channel
*/
- String getChannel();
+ String updateChannel();
/**
* Get the path to the running JAR file.
+ *
* @return The path to the running JAR file.
*/
- Path getRunningJarFilePath();
+ Path runningJarFilePath();
}
diff --git a/src/main/java/eu/m724/jarupdater/install/FileInstaller.java b/src/main/java/eu/m724/jarupdater/install/FileInstaller.java
new file mode 100644
index 0000000..9302d53
--- /dev/null
+++ b/src/main/java/eu/m724/jarupdater/install/FileInstaller.java
@@ -0,0 +1,20 @@
+package eu.m724.jarupdater.install;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+
+public class FileInstaller implements Installer {
+ public CompletableFuture install(Path source, Path destination) {
+ return CompletableFuture.runAsync(() -> {
+ try {
+ Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException e) {
+ throw new CompletionException(e);
+ }
+ });
+ }
+}
diff --git a/src/main/java/eu/m724/jarupdater/install/Installer.java b/src/main/java/eu/m724/jarupdater/install/Installer.java
new file mode 100644
index 0000000..60a01b3
--- /dev/null
+++ b/src/main/java/eu/m724/jarupdater/install/Installer.java
@@ -0,0 +1,15 @@
+package eu.m724.jarupdater.install;
+
+import java.nio.file.Path;
+import java.util.concurrent.CompletableFuture;
+
+public interface Installer {
+ /**
+ * Moves source into destination
+ *
+ * @param source The source file
+ * @param destination The destination file (not folder)
+ * @return A future which can throw {@link java.io.IOException}
+ */
+ CompletableFuture install(Path source, Path destination);
+}
diff --git a/src/main/java/eu/m724/jarupdater/live/GiteaMetadataDAO.java b/src/main/java/eu/m724/jarupdater/live/GiteaMetadataDAO.java
deleted file mode 100644
index 5d31146..0000000
--- a/src/main/java/eu/m724/jarupdater/live/GiteaMetadataDAO.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package eu.m724.jarupdater.live;
-
-public class GiteaMetadataDAO extends HttpMetadataDAO {
- public GiteaMetadataDAO(String url, String branch) {
- super(url + "/raw/branch/" + branch + "/data/");
- }
-}
diff --git a/src/main/java/eu/m724/jarupdater/live/HttpMetadataDAO.java b/src/main/java/eu/m724/jarupdater/live/HttpMetadataDAO.java
index bcaee7d..abbe6b5 100644
--- a/src/main/java/eu/m724/jarupdater/live/HttpMetadataDAO.java
+++ b/src/main/java/eu/m724/jarupdater/live/HttpMetadataDAO.java
@@ -1,73 +1,196 @@
package eu.m724.jarupdater.live;
import com.google.gson.Gson;
-import eu.m724.jarupdater.object.NoSuchVersionException;
-import eu.m724.jarupdater.object.Version;
+import com.google.gson.JsonSyntaxException;
+import eu.m724.jarupdater.download.HTTPException;
+import eu.m724.jarupdater.updater.NoSuchVersionException;
+import eu.m724.jarupdater.updater.Version;
-import java.io.IOException;
-import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
+import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.stream.Collectors;
+/**
+ * A {@link MetadataDAO} which downloads from HTTP.
+ */
public class HttpMetadataDAO implements MetadataDAO {
- private final String url;
+ private final URI uri;
+ private final String branding;
+ private final HttpClient httpClient;
+ private final Gson gson;
- public HttpMetadataDAO(String url) {
- this.url = url;
+ private HttpMetadataDAO(URI uri, String branding, HttpClient httpClient, Gson gson) {
+ this.uri = uri;
+ this.branding = branding;
+ this.httpClient = httpClient;
+ this.gson = gson;
}
-
+ /**
+ * Get the available channels.
+ *
+ * @return A future, which can throw:
+ *
+ * - {@link HTTPException} - if the request was unsuccessful
+ *
+ */
@Override
public CompletableFuture> getChannels() {
- String url = getFileUrl("channels.txt");
+ URI uri = getFileUri("channels.txt");
- return makeRequest(url).thenApply(response -> {
- if (response.statusCode() != 200)
- throw new CompletionException(new IOException("Server returned status code " + response.statusCode()));
+ return makeRequest(uri).thenApply(response -> {
+ if (response.statusCode() != 200) {
+ throw new CompletionException(new HTTPException("Unexpected status code: " + response.statusCode(), response));
+ }
return response.body().lines().collect(Collectors.toList());
});
}
+ /**
+ * Get the metadata of the specified version.
+ *
+ * @param channel The release channel
+ * @param versionName The version name
+ * @return A future, which can throw:
+ *
+ * - {@link NoSuchVersionException} - if there's no such version in this channel
+ * - {@link HTTPException} - if the request was unsuccessful
+ * - {@link JsonSyntaxException} - if the response is not valid JSON
+ *
+ */
@Override
- public CompletableFuture getMetadata(String channel, String versionLabel) {
- String url = getFileUrl(channel, versionLabel, "meta-v1.json");
+ public CompletableFuture getMetadata(String channel, String versionName) {
+ URI uri = getFileUri(channel, versionName, "meta-v1.json");
- return makeRequest(url).thenApply(response -> {
+ return makeRequest(uri).thenApply(response -> {
if (response.statusCode() == 404) {
- throw new CompletionException(new NoSuchVersionException(channel, versionLabel));
+ throw new CompletionException(new NoSuchVersionException(channel, versionName));
}
if (response.statusCode() != 200) {
- throw new CompletionException(new IOException("Server returned status code " + response.statusCode()));
+ throw new CompletionException(new HTTPException("Unexpected status code: " + response.statusCode(), response));
}
- return new Gson().fromJson(response.body(), Version.class);
+ try {
+ return gson.fromJson(response.body(), Version.class);
+ } catch (JsonSyntaxException e) {
+ throw new CompletionException(e);
+ }
});
}
-
- private CompletableFuture> makeRequest(String url) {
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(url))
- .header("User-Agent", "ju/1") // jar updater v1
+ private CompletableFuture> makeRequest(URI uri) {
+ HttpRequest request = HttpRequest.newBuilder(uri)
+ .header("User-Agent", "jarupdater/1.0 (%s)".formatted(branding))
.build();
- HttpClient.Builder httpClientBuilder = HttpClient.newBuilder()
- .followRedirects(HttpClient.Redirect.NORMAL)
- .proxy(ProxySelector.getDefault());
-
- HttpClient httpClient = httpClientBuilder.build();
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString());
}
- private String getFileUrl(String... paths) {
- return url + "/" + String.join("/", paths);
+ private URI getFileUri(String... paths) {
+ return uri.resolve(String.join("/", paths));
+ }
+
+ /**
+ * A builder class that builds {@link HttpMetadataDAO} instances.
+ */
+ public static class Builder {
+ private URI uri;
+ private String branding;
+ private HttpClient httpClient;
+ private Gson gson;
+
+ /**
+ * Sets the base URI.
+ * This cannot be null.
+ *
+ * @param uri The base URI
+ * @return This builder instance
+ * @throws NullPointerException If uri
is null
+ */
+ public Builder withUri(URI uri) {
+ Objects.requireNonNull(uri, "URI cannot be null");
+
+ this.uri = uri;
+ return this;
+ }
+
+ /**
+ * Sets the branding.
+ * Branding is used for the HTTP user agent.
+ * If null, will use the default.
+ *
+ * @param branding The branding
+ * @return This builder instance
+ */
+ public Builder withBranding(String branding) {
+ if (branding.length() > 100) {
+ throw new IllegalArgumentException("Branding must be at most 100 characters long");
+ }
+
+ if (branding.isBlank()) {
+ throw new IllegalArgumentException("Branding mustn't be blank");
+ }
+
+ this.branding = branding;
+ return this;
+ }
+
+ /**
+ * Sets the {@link HttpClient} instance.
+ * If null, will use the default.
+ *
+ * @param httpClient The {@link HttpClient} instance
+ * @return This builder instance
+ */
+ public Builder withHttpClient(HttpClient httpClient) {
+ this.httpClient = httpClient;
+ return this;
+ }
+
+ /**
+ * Sets the {@link Gson} instance used to parse JSON responses.
+ * If null, will use the default.
+ *
+ * @param gson The {@link Gson} instance
+ * @return This builder instance
+ */
+ public Builder withGson(Gson gson) {
+ this.gson = gson;
+ return this;
+ }
+
+ /**
+ * Builds a new {@link HttpMetadataDAO} instance using this builder's settings.
+ *
+ * @return The new {@link HttpMetadataDAO} instance using this builder's settings.
+ * @throws IllegalArgumentException If no URI set. See {@link Builder#withUri(URI)}
+ */
+ public HttpMetadataDAO build() {
+ Objects.requireNonNull(uri, "URI cannot be null");
+
+ if (branding == null) {
+ this.branding = "jarupdater";
+ }
+
+ if (httpClient == null) {
+ this.httpClient = HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+ }
+
+ if (gson == null) {
+ this.gson = new Gson();
+ }
+
+ return new HttpMetadataDAO(uri, branding, httpClient, gson);
+ }
}
}
diff --git a/src/main/java/eu/m724/jarupdater/live/MetadataDAO.java b/src/main/java/eu/m724/jarupdater/live/MetadataDAO.java
index e34f31d..88c8806 100644
--- a/src/main/java/eu/m724/jarupdater/live/MetadataDAO.java
+++ b/src/main/java/eu/m724/jarupdater/live/MetadataDAO.java
@@ -1,22 +1,22 @@
package eu.m724.jarupdater.live;
+import eu.m724.jarupdater.updater.Version;
+
import java.util.List;
import java.util.concurrent.CompletableFuture;
-import eu.m724.jarupdater.object.Version;
-
public interface MetadataDAO {
/**
- * get available channels online
- * @return a future which can throw ioexception
+ * Gets the available channels.
+ *
+ * @return A future that results in a list of the available channels.
*/
CompletableFuture> getChannels();
-
+
/**
- * get metadata of a version in channel
- * @param channel the channel
- * @param versionLabel basically version number
- * @return a future which can throw ioexcpeitons or nosuchfilexxception
+ * Gets the metadata of the specified version in the specified channel.
+ *
+ * @return A future that results in a list of the available channels.
*/
- CompletableFuture getMetadata(String channel, String versionLabel);
+ CompletableFuture getMetadata(String channel, String versionName);
}
diff --git a/src/main/java/eu/m724/jarupdater/live/MetadataFacade.java b/src/main/java/eu/m724/jarupdater/live/MetadataFacade.java
index fe55630..c673b2d 100644
--- a/src/main/java/eu/m724/jarupdater/live/MetadataFacade.java
+++ b/src/main/java/eu/m724/jarupdater/live/MetadataFacade.java
@@ -1,7 +1,7 @@
package eu.m724.jarupdater.live;
import eu.m724.jarupdater.environment.Environment;
-import eu.m724.jarupdater.object.Version;
+import eu.m724.jarupdater.updater.Version;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -9,34 +9,43 @@ import java.util.concurrent.CompletableFuture;
public class MetadataFacade {
private final Environment environment;
private final MetadataDAO metadataDao;
-
- private CompletableFuture> channels = null;
public MetadataFacade(Environment environment, MetadataDAO metadataDao) {
this.environment = environment;
this.metadataDao = metadataDao;
}
- public CompletableFuture> listChannels() {
- if (channels == null)
- channels = metadataDao.getChannels();
-
- return channels;
+ public CompletableFuture> getChannels() {
+ return metadataDao.getChannels();
}
-
+
+ /**
+ * Get the latest version's metadata.
+ * For possible exceptions, consult {@link MetadataDAO#getMetadata(String, String)} in your implementation of {@link MetadataDAO}
+ *
+ * @return A future which returns the latest version's metadata
+ */
public CompletableFuture getLatestVersionMetadata() {
- return getVersionMetadata("latest", true);
+ return getVersionMetadata("latest");
}
-
- public CompletableFuture getCurrentVersionMetadata() {
- return getVersionMetadata(environment.getRunningVersion());
+
+ /**
+ * Get the running (from the {@link Environment}) version's metadata.
+ * For possible exceptions, consult {@link MetadataDAO#getMetadata(String, String)} in your implementation of {@link MetadataDAO}
+ *
+ * @return A future which returns the running version's metadata
+ */
+ public CompletableFuture getRunningVersionMetadata() {
+ return getVersionMetadata(environment.runningVersion());
}
-
- public CompletableFuture getVersionMetadata(String version, boolean ignoreCache) {
- return metadataDao.getMetadata(environment.getChannel(), version);
- }
-
+
+ /**
+ * Get the specified version's metadata.
+ * For possible exceptions, consult {@link MetadataDAO#getMetadata(String, String)} in your implementation of {@link MetadataDAO}
+ *
+ * @return A future which returns the specified version's metadata
+ */
public CompletableFuture getVersionMetadata(String version) {
- return getVersionMetadata(version, false);
+ return metadataDao.getMetadata(environment.updateChannel(), version);
}
}
diff --git a/src/main/java/eu/m724/jarupdater/object/Version.java b/src/main/java/eu/m724/jarupdater/object/Version.java
deleted file mode 100644
index c03144a..0000000
--- a/src/main/java/eu/m724/jarupdater/object/Version.java
+++ /dev/null
@@ -1,84 +0,0 @@
-package eu.m724.jarupdater.object;
-
-import com.google.gson.annotations.SerializedName;
-
-import java.util.Objects;
-
-public class Version {
- /**
- * metadata file version
- */
- public static final int SPEC = 1;
-
- public Version(int id, long timestamp, String label, String fileUrl, String changelogUrl, String sha256) {
- this.id = id;
- this.timestamp = timestamp;
- this.label = label;
- this.fileUrl = fileUrl;
- this.changelogUrl = changelogUrl;
- this.sha256 = sha256;
- }
-
- private final int id;
- private final long timestamp;
- private final String label;
- @SerializedName("file")
- private final String fileUrl;
- @SerializedName("changelog")
- private final String changelogUrl;
- private final String sha256;
-
- /**
- * version id. increments with each version
- */
- public int getId() {
- return id;
- }
-
- /**
- * The release time of this version in Unix seconds
- */
- public long getTimestamp() {
- return timestamp;
- }
-
- /**
- * The label / name of this version, like 1.0.0
- */
- public String getLabel() {
- return label;
- }
-
- /**
- * URL of this version's downloadable JAR file
- */
- public String getFileUrl() {
- return fileUrl;
- }
-
- /**
- * The URL to this version's changelog
- */
- public String getChangelogUrl() {
- return changelogUrl;
- }
-
- /**
- * SHA-256 hash of this version's JAR file
- */
- public String getSha256() {
- return sha256;
- }
-
- @Override
- public boolean equals(Object o) {
- if (!(o instanceof Version)) return false;
- Version version = (Version) o;
- return id == version.id && timestamp == version.timestamp && Objects.equals(label, version.label) && Objects.equals(fileUrl, version.fileUrl) && Objects.equals(changelogUrl, version.changelogUrl) && Objects.equals(sha256, version.sha256);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(id, timestamp, label, fileUrl, changelogUrl, sha256);
- }
-}
diff --git a/src/main/java/eu/m724/jarupdater/updater/NoDownloadedUpdateException.java b/src/main/java/eu/m724/jarupdater/updater/NoDownloadedUpdateException.java
new file mode 100644
index 0000000..c35a037
--- /dev/null
+++ b/src/main/java/eu/m724/jarupdater/updater/NoDownloadedUpdateException.java
@@ -0,0 +1,7 @@
+package eu.m724.jarupdater.updater;
+
+/**
+ * Thrown when trying to install an update, but no update was downloaded.
+ */
+public class NoDownloadedUpdateException extends Exception {
+}
diff --git a/src/main/java/eu/m724/jarupdater/object/NoSuchVersionException.java b/src/main/java/eu/m724/jarupdater/updater/NoSuchVersionException.java
similarity index 89%
rename from src/main/java/eu/m724/jarupdater/object/NoSuchVersionException.java
rename to src/main/java/eu/m724/jarupdater/updater/NoSuchVersionException.java
index 25e269a..ef79bf2 100644
--- a/src/main/java/eu/m724/jarupdater/object/NoSuchVersionException.java
+++ b/src/main/java/eu/m724/jarupdater/updater/NoSuchVersionException.java
@@ -1,4 +1,4 @@
-package eu.m724.jarupdater.object;
+package eu.m724.jarupdater.updater;
import java.io.IOException;
diff --git a/src/main/java/eu/m724/jarupdater/updater/Updater.java b/src/main/java/eu/m724/jarupdater/updater/Updater.java
index 03a78c7..d4949bb 100644
--- a/src/main/java/eu/m724/jarupdater/updater/Updater.java
+++ b/src/main/java/eu/m724/jarupdater/updater/Updater.java
@@ -1,111 +1,159 @@
package eu.m724.jarupdater.updater;
+import eu.m724.jarupdater.download.DownloadProgress;
import eu.m724.jarupdater.download.Downloader;
import eu.m724.jarupdater.environment.Environment;
+import eu.m724.jarupdater.install.Installer;
+import eu.m724.jarupdater.live.MetadataDAO;
import eu.m724.jarupdater.live.MetadataFacade;
-import eu.m724.jarupdater.object.Version;
import eu.m724.jarupdater.verify.VerificationException;
import eu.m724.jarupdater.verify.Verifier;
-import java.io.IOException;
-import java.nio.file.NoSuchFileException;
+import java.net.URI;
import java.nio.file.Path;
+import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
+import java.util.function.Consumer;
+/**
+ * Represents an updater.
+ */
public class Updater {
protected final Environment environment;
protected final MetadataFacade metadataProvider;
protected final Downloader downloader;
+ protected final Installer installer;
protected final Verifier verifier;
protected CompletableFuture downloadFuture;
- public Updater(Environment environment, MetadataFacade metadataProvider, Downloader downloader, Verifier verifier) {
+ public Updater(Environment environment, MetadataFacade metadataProvider, Downloader downloader, Installer installer, Verifier verifier) {
this.environment = environment;
this.metadataProvider = metadataProvider;
this.downloader = downloader;
- this.verifier = verifier;
+ this.installer = installer;
+ this.verifier = verifier;
}
/**
- * List all channels
- * @return A future which returns the list of all channels
+ * List available update channels.
+ * For possible exceptions, consult {@link MetadataDAO#getMetadata(String, String)} in your implementation of {@link MetadataDAO}
+ *
+ * @return A future which returns the list of the available channels
*/
- public CompletableFuture> listChannels() {
- return metadataProvider.listChannels();
+ public CompletableFuture> getChannels() {
+ return metadataProvider.getChannels();
}
/**
- * Gets the configured update channel
- * @return the configured update channel
+ * Gets the update channel.
+ *
+ * @return the update channel
*/
- public String getChannel() {
- return environment.getChannel();
+ public String getUpdateChannel() {
+ return environment.updateChannel();
}
/**
- * Get the latest available version
- * @return A future which returns latest version metadata
+ * Get the latest available version. It might even be the running version.
+ * For possible exceptions, consult {@link MetadataDAO#getMetadata(String, String)} in your implementation of {@link MetadataDAO}
+ *
+ * @return A future which returns the latest version's metadata
*/
public CompletableFuture getLatestVersion() {
- CompletableFuture currentVersionFuture = metadataProvider.getCurrentVersionMetadata();
+ return metadataProvider.getLatestVersionMetadata();
+ }
+
+ /**
+ * Get information about this version.
+ * For possible exceptions, consult {@link MetadataDAO#getMetadata(String, String)} in your implementation of {@link MetadataDAO}
+ *
+ * @return A future which returns the running version's metadata
+ */
+ public CompletableFuture getRunningVersion() {
+ return metadataProvider.getRunningVersionMetadata();
+ }
+
+ /**
+ * Check if the running version is up to date.
+ * For possible exceptions, consult {@link MetadataDAO#getMetadata(String, String)} in your implementation of {@link MetadataDAO}
+ *
+ * @return A future which returns whether the running version is up to date.
+ */
+ public CompletableFuture isUpToDate() {
+ CompletableFuture runningVersionFuture = metadataProvider.getRunningVersionMetadata();
CompletableFuture latestVersionFuture = metadataProvider.getLatestVersionMetadata();
-
- return CompletableFuture.allOf(currentVersionFuture, latestVersionFuture).thenApply(v -> {
- Version currentVersion = currentVersionFuture.join();
+
+ return CompletableFuture.allOf(runningVersionFuture, latestVersionFuture).thenApply(v -> {
+ Version runningVersion = runningVersionFuture.join();
Version latestVersion = latestVersionFuture.join();
-
- if (currentVersion == null || latestVersion == null)
- throw new CompletionException(new IOException());
-
- return latestVersion.getId() > currentVersion.getId() ? latestVersion : null;
+
+ return latestVersion.id() == runningVersion.id();
});
}
/**
- * Get information about this version
- * @return A future which returns current version metadata
+ * Download the latest available version. Doesn't do any version checks, just downloads the latest.
+ * For possible exceptions, consult {@link Downloader#downloadAndVerify(URI, byte[], Consumer)} in your implementation of {@link Downloader}
+ *
+ * @param progressConsumer A consumer which receives progress updates.
+ * @return A future which returns the downloaded file. It can throw:
+ *
+ * - {@link IllegalArgumentException} - If the URI or hash in the metadata was malformed or invalid.
+ * - Other exceptions, as mentioned above
+ *
*/
- public CompletableFuture getCurrentVersion() {
- return metadataProvider.getCurrentVersionMetadata();
- }
-
- /**
- * Download the latest available version
- * @return a future which returns the downloaded file
- */
- public CompletableFuture downloadLatestVersion() {
+ public CompletableFuture downloadLatestVersion(Consumer progressConsumer) {
CompletableFuture latestVersionFuture = metadataProvider.getLatestVersionMetadata();
- downloadFuture = latestVersionFuture.thenComposeAsync(latestVersion -> {
- String url = latestVersion.getFileUrl();
- String hash = latestVersion.getSha256();
+ this.downloadFuture = latestVersionFuture.thenComposeAsync(latestVersion -> {
+ String url = latestVersion.fileUrl();
+ String hash = latestVersion.fileHash();
- return downloader.downloadAndVerify(url, hash);
+ URI uri = URI.create(url);
+ byte[] hashEncoded = HexFormat.of().parseHex(hash);
+
+ return downloader.downloadAndVerify(uri, hashEncoded, progressConsumer);
});
return downloadFuture;
}
+
+ /**
+ * Download the latest available version. Doesn't do any version checks, just downloads the latest.
+ * For possible exceptions, consult {@link Downloader#downloadAndVerify(URI, byte[], Consumer)} in your implementation of {@link Downloader}
+ *
+ * @return A future which returns the downloaded file.
+ */
+ public CompletableFuture downloadLatestVersion() {
+ return downloadLatestVersion(progress -> {});
+ }
/**
- * Installs the ALREADY DOWNLOADED latest version
- * You can call it immediately after {@link #downloadLatestVersion()}
- * @return a {@link CompletableFuture} that ends after move, it can throw some exceptions.
- * @throws NoSuchFileException if you didn't download it first
+ * Installs the downloaded latest version.
+ * Make sure to use {@link #downloadLatestVersion()} before
+ * For possible exceptions, consult {@link Installer#install(Path, Path)} in your implementation of {@link Installer}
+ *
+ * @return A future, which can throw:
+ *
+ * - {@link VerificationException} - If verification failed
+ * - Other exceptions, as mentioned above
+ *
*/
- public CompletableFuture installLatestVersion() throws NoSuchFileException {
- if (downloadFuture == null)
- throw new NoSuchFileException("Download it first");
+ public CompletableFuture installLatestVersion() {
+ if (downloadFuture == null) {
+ return CompletableFuture.failedFuture(new NoDownloadedUpdateException());
+ }
return downloadFuture.thenCompose(file -> {
try {
- verifier.verify(file.toString());
+ verifier.verify(file);
} catch (VerificationException e) {
throw new CompletionException(e);
}
- return downloader.install(file, environment.getRunningJarFilePath());
+ return installer.install(file, environment.runningJarFilePath());
});
}
diff --git a/src/main/java/eu/m724/jarupdater/updater/Version.java b/src/main/java/eu/m724/jarupdater/updater/Version.java
new file mode 100644
index 0000000..a2ab639
--- /dev/null
+++ b/src/main/java/eu/m724/jarupdater/updater/Version.java
@@ -0,0 +1,23 @@
+package eu.m724.jarupdater.updater;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ *
+ * @param id The version ID, which increments with each version.
+ * @param timestamp The release time of this version, in Unix seconds
+ * @param label The label (name) of this version, e.g., 1.0.0
+ * @param fileUrl The URL to the downloadable JAR file of this version
+ * @param changelogUrl The URL to the changelog of this version
+ * @param fileHash The SHA-256 hash of the file fileUrl
points to
+ */
+public record Version(
+ int id,
+ long timestamp,
+ String label,
+ @SerializedName("file") String fileUrl,
+ @SerializedName("changelog") String changelogUrl,
+ @SerializedName("sha256") String fileHash
+) {
+
+}
diff --git a/src/main/java/eu/m724/jarupdater/verify/SignatureVerifier.java b/src/main/java/eu/m724/jarupdater/verify/SignatureVerifier.java
index 3c4ae48..1b63497 100644
--- a/src/main/java/eu/m724/jarupdater/verify/SignatureVerifier.java
+++ b/src/main/java/eu/m724/jarupdater/verify/SignatureVerifier.java
@@ -2,9 +2,20 @@ package eu.m724.jarupdater.verify;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.file.Path;
+import java.security.CodeSigner;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.cert.Certificate;
import java.security.interfaces.RSAPublicKey;
-import java.util.HashSet;
-import java.util.Set;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.*;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+
/**
* Utility for verifying downloaded JARs with RSA signatures
*/
@@ -12,30 +23,129 @@ public class SignatureVerifier implements Verifier {
private final Set publicKeys = new HashSet<>();
/**
- * Load an RSA public key
- * @param inputStream the input stream of the public key file
+ * Reads and trusts an RSA public key
+ * @param inputStream The input stream to load the RSA public key to trust
+ * @throws IOException If something failed while reading
*/
- public void loadPublicKey(InputStream inputStream) throws IOException {
- RSAPublicKey publicKey = SignatureVerifierImpl.loadPublicKey(inputStream);
+ public void trustPublicKey(InputStream inputStream) throws IOException {
+ RSAPublicKey publicKey = loadPublicKeyFromInputStream(inputStream);
+ trustPublicKey(publicKey);
+ }
+
+ /**
+ * Trusts an RSA public key
+ * @param publicKey The RSA public key to trust
+ */
+ public void trustPublicKey(RSAPublicKey publicKey) {
publicKeys.add(publicKey);
}
+
/**
- * How much public keys loaded
- * @return amount of public keys loaded
+ * Verify a JAR using the loaded RSA public keys
+ *
+ * @param jarPath The path of the JAR file to verify
+ * @throws VerificationException If something went wrong or the JAR isn't signed with the trusted keys
*/
- public int publicKeysCount() {
- return publicKeys.size();
+ public void verify(Path jarPath) throws VerificationException {
+ verify(jarPath, publicKeys);
+ }
+
+ private void verify(Path jarPath, Collection publicKeys) throws VerificationException {
+ try (JarFile jarFile = new JarFile(jarPath.toFile(), true)) {
+ byte[] buffer = new byte[8192];
+ Enumeration entries = jarFile.entries();
+
+ // Get manifest to check signature files
+ Manifest manifest = jarFile.getManifest();
+ if (manifest == null) {
+ throw new VerificationException("JAR has no manifest");
+ }
+
+ while (entries.hasMoreElements()) {
+ JarEntry entry = entries.nextElement();
+
+ if (entry.isDirectory() || entry.getName().startsWith("META-INF/")) {
+ continue;
+ }
+
+ int bytesRead = 0;
+ // Read entry to trigger signature verification
+ try (InputStream inputStream = jarFile.getInputStream(entry)) {
+ while ((bytesRead += inputStream.read(buffer)) != -1) {
+ if (bytesRead > 1024 * 1024 * 100) { // unusual for a public key file to have >100 MiB
+ throw new IOException("File too large: " + entry.getName());
+ }
+ }
+ }
+
+ // Get signers for this entry
+ CodeSigner[] signers = entry.getCodeSigners();
+ if (signers == null || signers.length == 0) {
+ throw new VerificationException("Unsigned entry: " + entry.getName());
+ }
+
+ // Check if any signer's public key matches our RSA key
+ boolean keyMatch = false;
+
+ for (CodeSigner signer : signers) {
+ for (Certificate cert : signer.getSignerCertPath().getCertificates()) {
+ PublicKey certPublicKey = cert.getPublicKey();
+
+ if (certPublicKey instanceof RSAPublicKey rsaKey) {
+ for (RSAPublicKey publicKey : publicKeys) {
+ if (rsaKey.getModulus().equals(publicKey.getModulus()) &&
+ rsaKey.getPublicExponent().equals(publicKey.getPublicExponent())) {
+ keyMatch = true;
+ break;
+ }
+ }
+ }
+ }
+
+ if (keyMatch) {
+ break;
+ }
+ }
+
+ if (!keyMatch) {
+ throw new VerificationException("Entry " + entry.getName() + " signed with unknown key");
+ }
+ }
+ } catch (IOException e) {
+ throw new VerificationException("Verification error: " + e.getMessage(), e);
+ }
}
/**
- * Verify a JAR with loaded public keys
+ * Loads an RSA public key from a PEM file
*
- * @param jarPath the path of the JAR file
- * @throws VerificationException if something went wrong or not signed with known keys
+ * @param keyInputStream inputStream of the public key file
+ * @return {@link RSAPublicKey} instance of the public key
+ * @throws IOException if reading the key input stream failed
*/
- public void verify(String jarPath) throws VerificationException {
- SignatureVerifierImpl.verifyWithRsaKey(jarPath, publicKeys.toArray(RSAPublicKey[]::new));
+ private RSAPublicKey loadPublicKeyFromInputStream(InputStream keyInputStream) throws IOException {
+ // Read the key file
+ String keyContent = new String(keyInputStream.readAllBytes());
+
+ // Remove PEM headers and newlines
+ keyContent = keyContent.replace("-----BEGIN PUBLIC KEY-----", "")
+ .replace("-----END PUBLIC KEY-----", "")
+ .replaceAll("\\s+", "");
+
+ // Decode the key
+ byte[] keyBytes = Base64.getDecoder().decode(keyContent);
+
+ // Create public key specification
+ X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
+
+ // Generate public key
+ try {
+ KeyFactory kf = KeyFactory.getInstance("RSA");
+ return (RSAPublicKey) kf.generatePublic(spec);
+ } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
+ throw new IOException(e);
+ }
}
}
diff --git a/src/main/java/eu/m724/jarupdater/verify/SignatureVerifierImpl.java b/src/main/java/eu/m724/jarupdater/verify/SignatureVerifierImpl.java
deleted file mode 100644
index c947f76..0000000
--- a/src/main/java/eu/m724/jarupdater/verify/SignatureVerifierImpl.java
+++ /dev/null
@@ -1,129 +0,0 @@
-package eu.m724.jarupdater.verify;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.security.CodeSigner;
-import java.security.GeneralSecurityException;
-import java.security.KeyFactory;
-import java.security.PublicKey;
-import java.security.cert.Certificate;
-import java.security.interfaces.RSAPublicKey;
-import java.security.spec.X509EncodedKeySpec;
-import java.util.ArrayList;
-import java.util.Base64;
-import java.util.Enumeration;
-import java.util.List;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
-import java.util.jar.Manifest;
-
-public class SignatureVerifierImpl {
- /**
- * Loads an RSA public key from a PEM file
- *
- * @param keyInputStream inputStream of the public key file
- * @return {@link RSAPublicKey} instance of the public key
- * @throws IOException if reading the key input stream failed
- */
- public static RSAPublicKey loadPublicKey(InputStream keyInputStream) throws IOException {
- // Read the key file
- String keyContent = new String(keyInputStream.readAllBytes());
-
- // Remove PEM headers and newlines
- keyContent = keyContent.replace("-----BEGIN PUBLIC KEY-----", "")
- .replace("-----END PUBLIC KEY-----", "")
- .replaceAll("\\s+", "");
-
- // Decode the key
- byte[] keyBytes = Base64.getDecoder().decode(keyContent);
-
- // Create public key specification
- X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
-
- // Generate public key
- try {
- KeyFactory kf = KeyFactory.getInstance("RSA");
- return (RSAPublicKey) kf.generatePublic(spec);
- } catch (GeneralSecurityException e) {
- // because this shouldn't happen
- throw new RuntimeException(e);
- }
- }
-
- /**
- * Verifies if a JAR file's signature matches an RSA public key
- *
- * @param jarPath the path of the JAR file
- * @param publicKeys the {@link RSAPublicKey}s
- * @throws VerificationException if verification failed
- */
- public static void verifyWithRsaKey(String jarPath, RSAPublicKey... publicKeys) throws VerificationException {
- try {
- // Open the JAR file
- try (JarFile jarFile = new JarFile(jarPath, true)) {
- byte[] buffer = new byte[8192];
- Enumeration entries = jarFile.entries();
-
- // Get manifest to check signature files
- Manifest manifest = jarFile.getManifest();
- if (manifest == null) {
- throw new VerificationException("JAR has no manifest");
- }
-
- while (entries.hasMoreElements()) {
- JarEntry entry = entries.nextElement();
-
- if (entry.isDirectory() || entry.getName().startsWith("META-INF/")) {
- continue;
- }
-
- int bytesRead = 0;
- // Read entry to trigger signature verification
- try (InputStream is = jarFile.getInputStream(entry)) {
- while ((bytesRead += is.read(buffer)) != -1) {
- if (bytesRead > 1024 * 1024 * 100) { // unusual for a file to have >100 MiB
- throw new IOException("File too large: " + entry.getName());
- }
- }
- }
-
- // Get signers for this entry
- CodeSigner[] signers = entry.getCodeSigners();
- if (signers == null || signers.length == 0) {
- throw new VerificationException("Unsigned entry: " + entry.getName());
- }
-
- // Check if any signer's public key matches our RSA key
- boolean keyMatch = false;
- List signerPublicKeys = new ArrayList<>();
-
- for (CodeSigner signer : signers) {
- for (Certificate cert : signer.getSignerCertPath().getCertificates()) {
- PublicKey certPublicKey = cert.getPublicKey();
- if (certPublicKey instanceof RSAPublicKey) {
- RSAPublicKey rsaKey = (RSAPublicKey) certPublicKey;
- signerPublicKeys.add(Base64.getEncoder().encodeToString(rsaKey.getEncoded()));
-
- for (RSAPublicKey publicKey : publicKeys) {
- if (rsaKey.getModulus().equals(publicKey.getModulus()) &&
- rsaKey.getPublicExponent().equals(publicKey.getPublicExponent())) {
- keyMatch = true;
- break;
- }
- }
- }
- }
- if (keyMatch) break;
- }
-
- if (!keyMatch) {
- throw new VerificationException("Entry " + entry.getName() + " signed with unknown key");
- }
- }
- }
-
- } catch (IOException e) {
- throw new VerificationException("Verification error: " + e.getMessage(), e);
- }
- }
-}
diff --git a/src/main/java/eu/m724/jarupdater/verify/VerificationException.java b/src/main/java/eu/m724/jarupdater/verify/VerificationException.java
index c3d1922..81fe4d2 100644
--- a/src/main/java/eu/m724/jarupdater/verify/VerificationException.java
+++ b/src/main/java/eu/m724/jarupdater/verify/VerificationException.java
@@ -1,5 +1,8 @@
package eu.m724.jarupdater.verify;
+/**
+ * Thrown when there was a problem verifying the downloaded JAR
+ */
public class VerificationException extends Exception {
public VerificationException(String message) {
super(message);
diff --git a/src/main/java/eu/m724/jarupdater/verify/Verifier.java b/src/main/java/eu/m724/jarupdater/verify/Verifier.java
index e20137f..7f4e301 100644
--- a/src/main/java/eu/m724/jarupdater/verify/Verifier.java
+++ b/src/main/java/eu/m724/jarupdater/verify/Verifier.java
@@ -1,5 +1,7 @@
package eu.m724.jarupdater.verify;
+import java.nio.file.Path;
+
public interface Verifier {
- void verify(String jarPath) throws VerificationException;
+ void verify(Path jarPath) throws VerificationException;
}
diff --git a/src/test/java/eu/m724/jarupdater/download/DownloaderTest.java b/src/test/java/eu/m724/jarupdater/download/DownloaderTest.java
index 63002b8..1018a18 100644
--- a/src/test/java/eu/m724/jarupdater/download/DownloaderTest.java
+++ b/src/test/java/eu/m724/jarupdater/download/DownloaderTest.java
@@ -1,44 +1,36 @@
package eu.m724.jarupdater.download;
+import eu.m724.jarupdater.verify.VerificationException;
import org.junit.Test;
import java.io.IOException;
+import java.net.URI;
import java.nio.file.Path;
-import java.security.SignatureException;
+import java.util.HexFormat;
import java.util.concurrent.CompletionException;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.*;
public class DownloaderTest {
@Test
public void testDownloader() {
Downloader downloader = new MockDownloader();
- Path file = downloader.downloadAndVerify("good", "a9194ba3e955ba7482c6552894d4ca41b9bbafd86f4d90f3564c02fcb9e917c2").join();
+ Path file = downloader.downloadAndVerify(URI.create("good"), HexFormat.of().parseHex("a9194ba3e955ba7482c6552894d4ca41b9bbafd86f4d90f3564c02fcb9e917c2")).join();
assertNotNull(file);
try {
- downloader.downloadAndVerify("invalid", "678af2539cb4156f4e092d5e80de23aed7d9355774697267979b94e5cceec1b2").join();
+ downloader.downloadAndVerify(URI.create("invalid"), HexFormat.of().parseHex("678af2539cb4156f4e092d5e80de23aed7d9355774697267979b94e5cceec1b2")).join();
fail("this should have thrown");
} catch (CompletionException e) {
assert e.getCause() instanceof IOException;
}
try {
- downloader.downloadAndVerify("bad", "1dd7d814fa99d923eee9e483c25a02346c47f84dbc160a1a9f87e9b051e77ee1").join();
+ downloader.downloadAndVerify(URI.create("bad"), HexFormat.of().parseHex("1dd7d814fa99d923eee9e483c25a02346c47f84dbc160a1a9f87e9b051e77ee1")).join();
fail("this should have thrown");
} catch (CompletionException e) {
- assert e.getCause() instanceof SignatureException;
+ assert e.getCause() instanceof VerificationException;
}
-
- downloader.install(file, file).join();
- try {
- downloader.install(Path.of("ialsoexistfortesting"), file).join();
- fail("this should have thrown");
- } catch (CompletionException e) {
- assert e.getCause() instanceof IOException;
- }
-
}
}
diff --git a/src/test/java/eu/m724/jarupdater/download/MockDownloader.java b/src/test/java/eu/m724/jarupdater/download/MockDownloader.java
index b3c5272..5404d4b 100644
--- a/src/test/java/eu/m724/jarupdater/download/MockDownloader.java
+++ b/src/test/java/eu/m724/jarupdater/download/MockDownloader.java
@@ -1,42 +1,22 @@
package eu.m724.jarupdater.download;
+import eu.m724.jarupdater.verify.VerificationException;
+
import java.io.IOException;
+import java.net.URI;
import java.nio.file.Path;
-import java.security.SignatureException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
+import java.util.function.Consumer;
public class MockDownloader implements Downloader {
-
@Override
- public CompletableFuture downloadAndVerify(String url, String sha256hex) {
- CompletableFuture future = null;
-
- switch (url) {
- case "good":
- future = CompletableFuture.completedFuture(Path.of("ionlyexistfortesting"));
- break;
- case "invalid":
- future = CompletableFuture.failedFuture(new CompletionException(new IOException()));
- break;
- case "bad":
- future = CompletableFuture.failedFuture(new CompletionException(new SignatureException()));
- break;
- }
-
- return future;
+ public CompletableFuture downloadAndVerify(URI uri, byte[] expectedHash, Consumer progressConsumer) {
+ return switch (uri.toString()) {
+ case "good" -> CompletableFuture.completedFuture(Path.of("ionlyexistfortesting"));
+ case "invalid" -> CompletableFuture.failedFuture(new CompletionException(new IOException()));
+ case "bad" -> CompletableFuture.failedFuture(new CompletionException(new VerificationException("exception")));
+ default -> null;
+ };
}
-
- @Override
- public CompletableFuture install(Path source, Path destination) {
- CompletableFuture future = null;
-
- if (source.toString().equals("ionlyexistfortesting"))
- future = CompletableFuture.completedFuture(null);
- else
- future = CompletableFuture.failedFuture(new CompletionException(new IOException()));
-
- return future;
- }
-
}
diff --git a/src/test/java/eu/m724/jarupdater/environment/EnvironmentTest.java b/src/test/java/eu/m724/jarupdater/environment/EnvironmentTest.java
index a2b1f8d..533e767 100644
--- a/src/test/java/eu/m724/jarupdater/environment/EnvironmentTest.java
+++ b/src/test/java/eu/m724/jarupdater/environment/EnvironmentTest.java
@@ -9,8 +9,8 @@ public class EnvironmentTest {
public void testConstantEnvironment() {
Environment environment = new ConstantEnvironment("1.0", "stable", Path.of("idontexist"));
- assert environment.getRunningVersion().equals("1.0");
- assert environment.getChannel().equals("stable");
- assert environment.getRunningJarFilePath().equals(Path.of("idontexist"));
+ assert environment.runningVersion().equals("1.0");
+ assert environment.updateChannel().equals("stable");
+ assert environment.runningJarFilePath().equals(Path.of("idontexist"));
}
}
diff --git a/src/test/java/eu/m724/jarupdater/install/InstallerTest.java b/src/test/java/eu/m724/jarupdater/install/InstallerTest.java
new file mode 100644
index 0000000..992d539
--- /dev/null
+++ b/src/test/java/eu/m724/jarupdater/install/InstallerTest.java
@@ -0,0 +1,27 @@
+package eu.m724.jarupdater.install;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.concurrent.CompletionException;
+
+import static org.junit.Assert.fail;
+
+public class InstallerTest {
+ @Test
+ public void testInstaller() {
+ Installer installer = new MockInstaller();
+
+ Path asd = Path.of("asd");
+
+ installer.install(Path.of("iwillnotthrow"), asd).join();
+
+ try {
+ installer.install(Path.of("iwillthrow"), asd).join();
+ fail("this should have thrown");
+ } catch (CompletionException e) {
+ assert e.getCause() instanceof IOException;
+ }
+ }
+}
diff --git a/src/test/java/eu/m724/jarupdater/install/MockInstaller.java b/src/test/java/eu/m724/jarupdater/install/MockInstaller.java
new file mode 100644
index 0000000..0ef7941
--- /dev/null
+++ b/src/test/java/eu/m724/jarupdater/install/MockInstaller.java
@@ -0,0 +1,17 @@
+package eu.m724.jarupdater.install;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+
+public class MockInstaller implements Installer {
+ @Override
+ public CompletableFuture install(Path source, Path destination) {
+ if (source.toString().equals("iwillnotthrow")) {
+ return CompletableFuture.completedFuture(null);
+ } else {
+ return CompletableFuture.failedFuture(new CompletionException(new IOException()));
+ }
+ }
+}
diff --git a/src/test/java/eu/m724/jarupdater/live/MetadataTest.java b/src/test/java/eu/m724/jarupdater/live/MetadataTest.java
index 201b8c1..94dd25e 100644
--- a/src/test/java/eu/m724/jarupdater/live/MetadataTest.java
+++ b/src/test/java/eu/m724/jarupdater/live/MetadataTest.java
@@ -2,7 +2,7 @@ package eu.m724.jarupdater.live;
import eu.m724.jarupdater.environment.ConstantEnvironment;
import eu.m724.jarupdater.environment.Environment;
-import eu.m724.jarupdater.object.Version;
+import eu.m724.jarupdater.updater.Version;
import org.junit.Test;
import java.io.IOException;
@@ -19,17 +19,17 @@ public class MetadataTest {
MetadataDAO dao = new MockMetadataDAO();
MetadataFacade facade = new MetadataFacade(environment, dao);
- assert facade.listChannels().join().contains(environment.getChannel());
+ assert facade.getChannels().join().contains(environment.updateChannel());
- Version cur = facade.getCurrentVersionMetadata().join();
+ Version cur = facade.getRunningVersionMetadata().join();
Version lat = facade.getLatestVersionMetadata().join();
assert cur != null;
assert lat != null;
assertNotEquals(cur, lat);
- assertEquals(cur.getLabel(), environment.getRunningVersion());
- assertEquals("1.1", lat.getLabel());
+ assertEquals(cur.label(), environment.runningVersion());
+ assertEquals("1.1", lat.label());
try {
facade.getVersionMetadata("invalidversion").join();
diff --git a/src/test/java/eu/m724/jarupdater/live/MockMetadataDAO.java b/src/test/java/eu/m724/jarupdater/live/MockMetadataDAO.java
index 2ee0f1f..e7388fa 100644
--- a/src/test/java/eu/m724/jarupdater/live/MockMetadataDAO.java
+++ b/src/test/java/eu/m724/jarupdater/live/MockMetadataDAO.java
@@ -1,7 +1,7 @@
package eu.m724.jarupdater.live;
-import eu.m724.jarupdater.object.NoSuchVersionException;
-import eu.m724.jarupdater.object.Version;
+import eu.m724.jarupdater.updater.NoSuchVersionException;
+import eu.m724.jarupdater.updater.Version;
import java.util.Arrays;
import java.util.List;
@@ -17,11 +17,11 @@ public class MockMetadataDAO implements MetadataDAO {
}
@Override
- public CompletableFuture getMetadata(String channel, String versionLabel) {
+ public CompletableFuture getMetadata(String channel, String versionName) {
Version version = null;
String fileUrlPrefix = "http://127.0.0.1:17357/" + channel + "/";
- switch (versionLabel) {
+ switch (versionName) {
case "1.0":
version = new Version(1, 100, "1.0", fileUrlPrefix + "1.0.jar", null, "dd3822ed965b2820aa0800f04b86f26d0b656fca3b97ddd264046076f81e3a24");
break;
@@ -30,7 +30,7 @@ public class MockMetadataDAO implements MetadataDAO {
version = new Version(2, 200, "1.1", fileUrlPrefix + "1.1.jar", null,"4d59994f607b89987d5bff430a4229edbbb045b3da9eaf1c63fa8e5fb208d824");
break;
default:
- return CompletableFuture.failedFuture(new CompletionException(new NoSuchVersionException(channel, versionLabel)));
+ return CompletableFuture.failedFuture(new CompletionException(new NoSuchVersionException(channel, versionName)));
}
return CompletableFuture.completedFuture(version);
diff --git a/src/test/java/eu/m724/jarupdater/updater/UpdaterTest.java b/src/test/java/eu/m724/jarupdater/updater/UpdaterTest.java
index 8ead3ed..65ecd81 100644
--- a/src/test/java/eu/m724/jarupdater/updater/UpdaterTest.java
+++ b/src/test/java/eu/m724/jarupdater/updater/UpdaterTest.java
@@ -4,10 +4,11 @@ import eu.m724.jarupdater.download.Downloader;
import eu.m724.jarupdater.download.MockDownloader;
import eu.m724.jarupdater.environment.ConstantEnvironment;
import eu.m724.jarupdater.environment.Environment;
+import eu.m724.jarupdater.install.Installer;
+import eu.m724.jarupdater.install.MockInstaller;
import eu.m724.jarupdater.live.MetadataDAO;
import eu.m724.jarupdater.live.MetadataFacade;
import eu.m724.jarupdater.live.MockMetadataDAO;
-import eu.m724.jarupdater.object.Version;
import eu.m724.jarupdater.verify.MockVerifier;
import eu.m724.jarupdater.verify.Verifier;
import org.junit.Before;
@@ -33,21 +34,22 @@ public class UpdaterTest {
MetadataFacade metadataFacade = new MetadataFacade(environment, metadataDAO);
Downloader downloader = new MockDownloader();
+ Installer installer = new MockInstaller();
Verifier verifier = new MockVerifier();
- this.updater = new Updater(environment, metadataFacade, downloader, verifier);
+ this.updater = new Updater(environment, metadataFacade, downloader, installer, verifier);
}
@Test
public void testChannels() {
- List channels = updater.listChannels().join();
+ List channels = updater.getChannels().join();
assertEquals(channels, Arrays.asList("stable", "beta", "alpha"));
}
@Test
public void testCurrentVersion() {
- Version currentVersion = updater.getCurrentVersion().join();
+ Version currentVersion = updater.getRunningVersion().join();
assertEquals(expectedCurrentVersion, currentVersion);
}
diff --git a/src/test/java/eu/m724/jarupdater/verify/MockVerifier.java b/src/test/java/eu/m724/jarupdater/verify/MockVerifier.java
index 48bb0ff..22dc065 100644
--- a/src/test/java/eu/m724/jarupdater/verify/MockVerifier.java
+++ b/src/test/java/eu/m724/jarupdater/verify/MockVerifier.java
@@ -1,8 +1,10 @@
package eu.m724.jarupdater.verify;
+import java.nio.file.Path;
+
public class MockVerifier implements Verifier {
@Override
- public void verify(String jarPath) throws VerificationException {
+ public void verify(Path jarPath) throws VerificationException {
return;
}
}