Some refactoring

Closes #5
This commit is contained in:
Minecon724 2025-06-02 16:20:37 +02:00
commit 61362336a9
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
35 changed files with 920 additions and 537 deletions

1
.idea/compiler.xml generated
View file

@ -6,6 +6,7 @@
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="jarupdater" />
</profile>
</annotationProcessing>
<bytecodeTargetLevel>

2
.idea/misc.xml generated
View file

@ -8,5 +8,5 @@
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="temurin-11" project-jdk-type="JavaSDK" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="temurin-17" project-jdk-type="JavaSDK" />
</project>

View file

@ -5,7 +5,7 @@
<version>0.2.1-SNAPSHOT</version>
<properties>
<maven.compiler.release>11</maven.compiler.release>
<maven.compiler.release>17</maven.compiler.release>
<jarsigner.keystore>${project.basedir}/testkeystore.jks</jarsigner.keystore>
<jarsigner.alias>testkey</jarsigner.alias>
@ -45,11 +45,12 @@
<build>
<plugins>
<plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-release-plugin</artifactId>
<version>3.1.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jarsigner-plugin</artifactId>

View file

@ -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 <code>read</code> bytes.
* @param read The bytes now read
* @return The new DownloadProgress
*/
public DownloadProgress plus(int read) {
return new DownloadProgress(downloadedBytes + read, fileSizeBytes);
}
}

View file

@ -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<Path> 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<Void> install(Path source, Path destination);
CompletableFuture<Path> downloadAndVerify(URI uri, byte[] expectedHash, Consumer<DownloadProgress> progressConsumer);
default CompletableFuture<Path> downloadAndVerify(URI uri, byte[] expectedHash) {
return downloadAndVerify(uri, expectedHash, progress -> {});
}
}

View file

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

View file

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

View file

@ -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<Path> 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<HttpResponse<InputStream>> 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<Void> 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;
}
}

View file

@ -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.<br>
* The future can throw:
* <ul>
* <li>{@link IOException} - multiple possible reasons</li>
* <li>{@link HashMismatchException} - when the fileHash of the downloaded file doesn't match the expected fileHash</li>
* <li>{@link InterruptedException} - when the download is interrupted</li>
* <li>{@link HTTPException} - when the response's status code is not 200</li>
* </ul>
*
* @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<Path> downloadAndVerify(URI uri, byte[] expectedHash, Consumer<DownloadProgress> progressConsumer) {
HttpRequest request = HttpRequest.newBuilder(uri)
.header("User-Agent", "jarupdater/1.0 (%s)".formatted(branding))
.build();
CompletableFuture<HttpResponse<InputStream>> 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.<br>
* The future can throw:
* <ul>
* <li>{@link IOException} - multiple possible reasons</li>
* <li>{@link HashMismatchException} - when the fileHash of the downloaded file doesn't match the expected fileHash</li>
* <li>{@link InterruptedException} - when the download is interrupted</li>
* <li>{@link HTTPException} - when the response's status code is not 200</li>
* </ul>
*
* @param uri The file URI
* @param expectedHash The expected SHA-256 fileHash
* @return A future
*/
@Override
public CompletableFuture<Path> 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.<br>
* The default is a generic <code>jarupdater</code>.
*
* @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}.<br>
* 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";
}
}

View file

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

View file

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

View file

@ -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<Void> install(Path source, Path destination) {
return CompletableFuture.runAsync(() -> {
try {
Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new CompletionException(e);
}
});
}
}

View file

@ -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<Void> install(Path source, Path destination);
}

View file

@ -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/");
}
}

View file

@ -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.<br>
*
* @return A future, which can throw:
* <ul>
* <li>{@link HTTPException} - if the request was unsuccessful</li>
* </ul>
*/
@Override
public CompletableFuture<List<String>> 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:
* <ul>
* <li>{@link NoSuchVersionException} - if there's no such version in this channel</li>
* <li>{@link HTTPException} - if the request was unsuccessful</li>
* <li>{@link JsonSyntaxException} - if the response is not valid JSON</li>
* </ul>
*/
@Override
public CompletableFuture<Version> getMetadata(String channel, String versionLabel) {
String url = getFileUrl(channel, versionLabel, "meta-v1.json");
public CompletableFuture<Version> 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<HttpResponse<String>> makeRequest(String url) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", "ju/1") // jar updater v1
private CompletableFuture<HttpResponse<String>> 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.<br>
* This cannot be null.
*
* @param uri The base URI
* @return This builder instance
* @throws NullPointerException If <code>uri</code> 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.<br>
* 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.<br>
* 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.<br>
* 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);
}
}
}

View file

@ -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<List<String>> 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<Version> getMetadata(String channel, String versionLabel);
CompletableFuture<Version> getMetadata(String channel, String versionName);
}

View file

@ -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<List<String>> channels = null;
public MetadataFacade(Environment environment, MetadataDAO metadataDao) {
this.environment = environment;
this.metadataDao = metadataDao;
}
public CompletableFuture<List<String>> listChannels() {
if (channels == null)
channels = metadataDao.getChannels();
return channels;
public CompletableFuture<List<String>> getChannels() {
return metadataDao.getChannels();
}
/**
* Get the latest version's metadata.<br>
* 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<Version> getLatestVersionMetadata() {
return getVersionMetadata("latest", true);
return getVersionMetadata("latest");
}
public CompletableFuture<Version> getCurrentVersionMetadata() {
return getVersionMetadata(environment.getRunningVersion());
/**
* Get the running (from the {@link Environment}) version's metadata.<br>
* 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<Version> getRunningVersionMetadata() {
return getVersionMetadata(environment.runningVersion());
}
public CompletableFuture<Version> getVersionMetadata(String version, boolean ignoreCache) {
return metadataDao.getMetadata(environment.getChannel(), version);
}
/**
* Get the specified version's metadata.<br>
* 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<Version> getVersionMetadata(String version) {
return getVersionMetadata(version, false);
return metadataDao.getMetadata(environment.updateChannel(), version);
}
}

View file

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

View file

@ -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 {
}

View file

@ -1,4 +1,4 @@
package eu.m724.jarupdater.object;
package eu.m724.jarupdater.updater;
import java.io.IOException;

View file

@ -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<Path> 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.<br>
* 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<List<String>> listChannels() {
return metadataProvider.listChannels();
public CompletableFuture<List<String>> 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.<br>
* 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<Version> getLatestVersion() {
CompletableFuture<Version> currentVersionFuture = metadataProvider.getCurrentVersionMetadata();
return metadataProvider.getLatestVersionMetadata();
}
/**
* Get information about this version.<br>
* 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<Version> 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<Boolean> isUpToDate() {
CompletableFuture<Version> runningVersionFuture = metadataProvider.getRunningVersionMetadata();
CompletableFuture<Version> 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.<br>
* 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:
* <ul>
* <li>{@link IllegalArgumentException} - If the URI or hash in the metadata was malformed or invalid.</li>
* <li>Other exceptions, as mentioned above</li>
* </ul>
*/
public CompletableFuture<Version> getCurrentVersion() {
return metadataProvider.getCurrentVersionMetadata();
}
/**
* Download the latest available version
* @return a future which returns the downloaded file
*/
public CompletableFuture<Path> downloadLatestVersion() {
public CompletableFuture<Path> downloadLatestVersion(Consumer<DownloadProgress> progressConsumer) {
CompletableFuture<Version> 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.<br>
* 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<Path> downloadLatestVersion() {
return downloadLatestVersion(progress -> {});
}
/**
* Installs the ALREADY DOWNLOADED latest version<br>
* 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.<br>
* <strong>Make sure to use {@link #downloadLatestVersion()} before</strong><br>
* For possible exceptions, consult {@link Installer#install(Path, Path)} in your implementation of {@link Installer}
*
* @return A future, which can throw:
* <ul>
* <li>{@link VerificationException} - If verification failed</li>
* <li>Other exceptions, as mentioned above</li>
* </ul>
*/
public CompletableFuture<Void> installLatestVersion() throws NoSuchFileException {
if (downloadFuture == null)
throw new NoSuchFileException("Download it first");
public CompletableFuture<Void> 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());
});
}

View file

@ -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 <code>fileUrl</code> points to
*/
public record Version(
int id,
long timestamp,
String label,
@SerializedName("file") String fileUrl,
@SerializedName("changelog") String changelogUrl,
@SerializedName("sha256") String fileHash
) {
}

View file

@ -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<RSAPublicKey> 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<RSAPublicKey> publicKeys) throws VerificationException {
try (JarFile jarFile = new JarFile(jarPath.toFile(), true)) {
byte[] buffer = new byte[8192];
Enumeration<JarEntry> 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);
}
}
}

View file

@ -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<JarEntry> 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<String> 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);
}
}
}

View file

@ -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);

View file

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

View file

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

View file

@ -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<Path> downloadAndVerify(String url, String sha256hex) {
CompletableFuture<Path> 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<Path> downloadAndVerify(URI uri, byte[] expectedHash, Consumer<DownloadProgress> 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<Void> install(Path source, Path destination) {
CompletableFuture<Void> future = null;
if (source.toString().equals("ionlyexistfortesting"))
future = CompletableFuture.completedFuture(null);
else
future = CompletableFuture.failedFuture(new CompletionException(new IOException()));
return future;
}
}

View file

@ -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"));
}
}

View file

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

View file

@ -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<Void> install(Path source, Path destination) {
if (source.toString().equals("iwillnotthrow")) {
return CompletableFuture.completedFuture(null);
} else {
return CompletableFuture.failedFuture(new CompletionException(new IOException()));
}
}
}

View file

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

View file

@ -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<Version> getMetadata(String channel, String versionLabel) {
public CompletableFuture<Version> 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);

View file

@ -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<String> channels = updater.listChannels().join();
List<String> 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);
}

View file

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