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