mirror of
https://github.com/cryptomator/cryptomator.git
synced 2026-08-01 11:08:23 -04:00
move package related tasks to the importtemplate package
keeps FXApplicationWindows slim Signed-off-by: Armin Schrenk <armin.schrenk@skymatic.de>
This commit is contained in:
@@ -9,9 +9,7 @@ import org.cryptomator.ui.dialogs.Dialogs;
|
||||
import org.cryptomator.ui.dialogs.SimpleDialog;
|
||||
import org.cryptomator.ui.error.ErrorComponent;
|
||||
import org.cryptomator.ui.eventview.EventViewComponent;
|
||||
import org.cryptomator.ui.importtemplate.ImportTemplateComponent;
|
||||
import org.cryptomator.ui.importtemplate.MalformedTemplateException;
|
||||
import org.cryptomator.ui.importtemplate.VaultTemplate;
|
||||
import org.cryptomator.ui.importtemplate.ImportTemplateWindows;
|
||||
import org.cryptomator.ui.lock.LockComponent;
|
||||
import org.cryptomator.ui.mainwindow.MainWindowComponent;
|
||||
import org.cryptomator.ui.notification.NotificationComponent;
|
||||
@@ -40,7 +38,6 @@ import java.awt.desktop.AppReopenedListener;
|
||||
import java.awt.desktop.QuitResponse;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.function.Supplier;
|
||||
@@ -64,7 +61,7 @@ public class FxApplicationWindows {
|
||||
private final ExecutorService executor;
|
||||
private final VaultOptionsComponent.Factory vaultOptionsWindow;
|
||||
private final ShareVaultComponent.Factory shareVaultWindow;
|
||||
private final ImportTemplateComponent.Factory importTemplateWindow;
|
||||
private final ImportTemplateWindows importTemplateWindows;
|
||||
private final FilteredList<Window> visibleWindows;
|
||||
private final Dialogs dialogs;
|
||||
|
||||
@@ -80,7 +77,7 @@ public class FxApplicationWindows {
|
||||
ErrorComponent.Factory errorWindowFactory, //
|
||||
VaultOptionsComponent.Factory vaultOptionsWindow, //
|
||||
ShareVaultComponent.Factory shareVaultWindow, //
|
||||
ImportTemplateComponent.Factory importTemplateWindow, //
|
||||
ImportTemplateWindows importTemplateWindows, //
|
||||
EventViewComponent.Factory eventViewWindowFactory, //
|
||||
NotificationComponent.Factory notificationWindowFactory, //
|
||||
ExecutorService executor, //
|
||||
@@ -99,7 +96,7 @@ public class FxApplicationWindows {
|
||||
this.executor = executor;
|
||||
this.vaultOptionsWindow = vaultOptionsWindow;
|
||||
this.shareVaultWindow = shareVaultWindow;
|
||||
this.importTemplateWindow = importTemplateWindow;
|
||||
this.importTemplateWindows = importTemplateWindows;
|
||||
this.visibleWindows = Window.getWindows().filtered(Window::isShowing);
|
||||
this.dialogs = dialogs;
|
||||
}
|
||||
@@ -152,50 +149,17 @@ public class FxApplicationWindows {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the import dialog for the given vault template.
|
||||
* <p>
|
||||
* The archive is unpacked and validated up front on a background thread, so a template that could never be imported
|
||||
* is rejected before the user is asked to choose a storage location - in which case the returned stage is the main
|
||||
* window, having shown an error dialog. On success the unpacked template is handed to the import window, which owns
|
||||
* it from then on and discards it when it closes.
|
||||
* Shows the vault template import flow, which decides for itself whether the template can be imported at all.
|
||||
* Unexpected failures (as opposed to an unusable template) surface in the generic error window.
|
||||
*/
|
||||
public CompletionStage<Stage> showImportTemplateWindow(String name, byte[] template) {
|
||||
return showMainWindow().thenComposeAsync(mainWindow -> //
|
||||
VaultTemplate.extractAsync(template, executor) //
|
||||
.handleAsync((extractedTemplate, throwable) -> {
|
||||
if (throwable != null) {
|
||||
return reportFailedImport(unwrap(throwable), mainWindow);
|
||||
}
|
||||
try {
|
||||
var component = importTemplateWindow.create(name, extractedTemplate);
|
||||
component.showImportTemplateWindow();
|
||||
return component.window();
|
||||
} catch (RuntimeException e) {
|
||||
extractedTemplate.close(); //nothing took ownership, so the temp dir is ours to discard
|
||||
throw e;
|
||||
}
|
||||
}, Platform::runLater) //
|
||||
).whenComplete(this::reportErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the main window, which is what remains visible once the user dismisses the error
|
||||
*/
|
||||
private Stage reportFailedImport(Throwable cause, Stage mainWindow) {
|
||||
if (cause instanceof MalformedTemplateException) {
|
||||
// a dead end: no storage location the user could pick would make this template work
|
||||
LOG.error("Vault template is malformed.", cause);
|
||||
dialogs.prepareMalformedTemplateDialog(mainWindow).build().showAndWait();
|
||||
} else {
|
||||
LOG.error("Failed to unpack vault template.", cause);
|
||||
showErrorWindow(cause, mainWindow, null);
|
||||
}
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
private static Throwable unwrap(Throwable throwable) {
|
||||
var cause = throwable.getCause();
|
||||
return throwable instanceof CompletionException && cause != null ? cause : throwable;
|
||||
return showMainWindow() //
|
||||
.thenComposeAsync(_ -> importTemplateWindows.extractAndShowImportTemplateWindow(name, template), Platform::runLater) //
|
||||
.exceptionallyAsync(e -> {
|
||||
showErrorWindow(e, primaryStage, null);
|
||||
return primaryStage;
|
||||
}, Platform::runLater) //
|
||||
.whenComplete(this::reportErrors);
|
||||
}
|
||||
|
||||
public CompletionStage<Stage> showVaultOptionsWindow(Vault vault, SelectedVaultOptionsTab tab) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.cryptomator.ui.importtemplate;
|
||||
|
||||
import org.cryptomator.ui.dialogs.Dialogs;
|
||||
import org.cryptomator.ui.fxapp.FxApplicationScoped;
|
||||
import org.cryptomator.ui.fxapp.PrimaryStage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javafx.application.Platform;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
/**
|
||||
* Entry point for showing the vault template import flow.
|
||||
* <p>
|
||||
* Everything the flow needs to decide - unpacking the archive, and choosing between the import dialog and the
|
||||
* "malformed template" dialog.
|
||||
*/
|
||||
@FxApplicationScoped
|
||||
public class ImportTemplateWindows {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ImportTemplateWindows.class);
|
||||
|
||||
private final ImportTemplateComponent.Factory importTemplateWindow;
|
||||
private final Dialogs dialogs;
|
||||
private final Stage primaryStage;
|
||||
private final ExecutorService executor;
|
||||
|
||||
@Inject
|
||||
ImportTemplateWindows(ImportTemplateComponent.Factory importTemplateWindow, //
|
||||
Dialogs dialogs, //
|
||||
@PrimaryStage Stage primaryStage, //
|
||||
ExecutorService executor) {
|
||||
this.importTemplateWindow = importTemplateWindow;
|
||||
this.dialogs = dialogs;
|
||||
this.primaryStage = primaryStage;
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks the given archive off the FX thread and shows the import dialog for it.
|
||||
* <p>
|
||||
* A template that could never be imported is rejected before the user is asked to choose a storage location: in that
|
||||
* case {@link #showMalformedTemplateDialog()} is shown instead and the returned stage is the primary stage. Failures
|
||||
* that are <em>not</em> the template's fault (the file system refusing to unpack it) complete the stage
|
||||
* exceptionally, leaving the generic error display to the caller.
|
||||
*
|
||||
* @param name the fixed vault name
|
||||
* @param archive the ZIP archive bytes
|
||||
* @return the import window, or the primary stage if the template was rejected
|
||||
*/
|
||||
public CompletionStage<Stage> extractAndShowImportTemplateWindow(String name, byte[] archive) {
|
||||
return extractAsync(archive)
|
||||
.handleAsync((template, throwable) -> {
|
||||
if (throwable != null) {
|
||||
var cause = throwable.getCause() != null ? throwable.getCause() : throwable;
|
||||
if (cause instanceof MalformedTemplateException) {
|
||||
// template itself is invalid
|
||||
LOG.error("Vault template is malformed.", cause);
|
||||
return showMalformedTemplateDialog();
|
||||
} else {
|
||||
LOG.error("Failed to unpack vault template.", cause);
|
||||
throw new CompletionException(cause);
|
||||
}
|
||||
}
|
||||
try {
|
||||
var component = importTemplateWindow.create(name, template);
|
||||
component.showImportTemplateWindow();
|
||||
return component.window();
|
||||
} catch (RuntimeException e) {
|
||||
template.close(); //nothing took ownership, so the temporary directory is ours to discard
|
||||
throw e;
|
||||
}
|
||||
}, Platform::runLater);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async Wrapper for {@link VaultTemplate#extract(byte[])}
|
||||
* <p>
|
||||
* Failures arrive as a {@link CompletionException} wrapping the cause: a {@link MalformedTemplateException}
|
||||
* means the template itself is a dead end, any other {@link IOException} means unpacking failed for
|
||||
* an environmental reason.
|
||||
*
|
||||
* @param archive the ZIP archive bytes
|
||||
* @return the unpacked template, which the caller must close
|
||||
* @see VaultTemplate#extract(byte[])
|
||||
*/
|
||||
CompletionStage<VaultTemplate> extractAsync(byte[] archive) {
|
||||
return CompletableFuture.supplyAsync(()-> {
|
||||
try {
|
||||
return VaultTemplate.extract(archive);
|
||||
} catch (IOException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the user that the template cannot be imported. Must be called on the FX thread.
|
||||
*
|
||||
* @return the primary stage, which is what remains visible once the user dismisses the dialog
|
||||
*/
|
||||
public Stage showMalformedTemplateDialog() {
|
||||
dialogs.prepareMalformedTemplateDialog(primaryStage).build().showAndWait();
|
||||
return primaryStage;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -49,28 +49,6 @@ public final class VaultTemplate implements AutoCloseable {
|
||||
this.hubUrl = hubUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks and validates the given archive on {@code executor}, off the FX thread.
|
||||
* <p>
|
||||
* Failures arrive as a {@link CompletionException} wrapping the cause, so callers should unwrap before deciding
|
||||
* what to show: a {@link MalformedTemplateException} means the template itself is a dead end, any other
|
||||
* {@link IOException} means unpacking failed for an environmental reason.
|
||||
*
|
||||
* @param archive the ZIP archive bytes
|
||||
* @param executor the executor to unpack on
|
||||
* @return the unpacked template, which the caller must close
|
||||
* @see #extract(byte[])
|
||||
*/
|
||||
public static CompletionStage<VaultTemplate> extractAsync(byte[] archive, Executor executor) {
|
||||
return CompletableFuture.supplyAsync(()-> {
|
||||
try {
|
||||
return VaultTemplate.extract(archive);
|
||||
} catch (IOException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks the given archive to a temporary directory and validates it.
|
||||
* <p>
|
||||
@@ -92,7 +70,8 @@ public final class VaultTemplate implements AutoCloseable {
|
||||
: Files.createTempDirectory(tempParent, "vault-template-");
|
||||
try {
|
||||
var vaultRoot = VaultTemplateExtractor.extractTo(archive, tempDir);
|
||||
return new VaultTemplate(tempDir, vaultRoot, readHubUrl(vaultRoot));
|
||||
var hubURL = readHubUrl(vaultRoot);
|
||||
return new VaultTemplate(tempDir, vaultRoot, hubURL);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
VaultTemplateExtractor.deleteQuietly(tempDir);
|
||||
throw e;
|
||||
@@ -120,7 +99,7 @@ public final class VaultTemplate implements AutoCloseable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hub this template's vault belongs to, or {@code null} if its config declares none.
|
||||
* The Hub instance which generated the template, or {@code null} if its config declares none.
|
||||
* <p>
|
||||
* Unverified - see the class documentation.
|
||||
*/
|
||||
|
||||
@@ -60,24 +60,6 @@ final class VaultTemplateExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an unpacked vault to its final location, creating missing parent directories.
|
||||
*
|
||||
* @param vaultRoot the unpacked vault directory
|
||||
* @param destination the target vault directory, which must not yet exist
|
||||
* @throws FileAlreadyExistsException if {@code destination} already exists
|
||||
*/
|
||||
static void moveToDestination(Path vaultRoot, Path destination) throws IOException {
|
||||
if (Files.exists(destination)) {
|
||||
throw new FileAlreadyExistsException(destination.toString());
|
||||
}
|
||||
Path parent = destination.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
move(vaultRoot, destination);
|
||||
}
|
||||
|
||||
private static Path unzip(Path zipFile, Path targetDir) throws IOException {
|
||||
Path normalizedTarget = targetDir.normalize();
|
||||
try (FileSystem zipFs = FileSystems.newFileSystem(zipFile)) {
|
||||
@@ -160,6 +142,24 @@ final class VaultTemplateExtractor {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an unpacked vault to its final location, creating missing parent directories.
|
||||
*
|
||||
* @param vaultRoot the unpacked vault directory
|
||||
* @param destination the target vault directory, which must not yet exist
|
||||
* @throws FileAlreadyExistsException if {@code destination} already exists
|
||||
*/
|
||||
static void moveToDestination(Path vaultRoot, Path destination) throws IOException {
|
||||
if (Files.exists(destination)) {
|
||||
throw new FileAlreadyExistsException(destination.toString());
|
||||
}
|
||||
Path parent = destination.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
move(vaultRoot, destination);
|
||||
}
|
||||
|
||||
private static void move(Path source, Path destination) throws IOException {
|
||||
try {
|
||||
Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.cryptomator.ui.importtemplate;
|
||||
|
||||
import org.cryptomator.JavaFXUtil;
|
||||
import org.cryptomator.ui.dialogs.Dialogs;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javafx.stage.Stage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class ImportTemplateWindowsTest {
|
||||
|
||||
private static final byte[] CIPHERTEXT = "some-ciphertext".getBytes(StandardCharsets.UTF_8);
|
||||
private static final String API_BASE_URL = "https://hub.example.com/api";
|
||||
|
||||
private ExecutorService executor;
|
||||
private Stage primaryStage;
|
||||
private ImportTemplateWindows inTest;
|
||||
|
||||
@BeforeAll
|
||||
public static void initJavaFx() throws InterruptedException {
|
||||
// extractAndShowImportTemplateWindow completes on the FX thread
|
||||
var isRunning = JavaFXUtil.startPlatform();
|
||||
Assumptions.assumeTrue(isRunning);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
public void setup() {
|
||||
executor = Executors.newSingleThreadExecutor();
|
||||
primaryStage = mock(Stage.class);
|
||||
inTest = spy(new ImportTemplateWindows(mock(ImportTemplateComponent.Factory.class), mock(Dialogs.class), primaryStage, executor));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void teardown() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractAsync yields the unpacked template")
|
||||
public void testExtractAsync() throws Exception {
|
||||
var zip = zip(Map.of("vault.cryptomator", hubVaultConfig()));
|
||||
|
||||
var template = inTest.extractAsync(zip).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
|
||||
try (template) {
|
||||
Assertions.assertEquals(API_BASE_URL + "/", template.hubUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractAsync hands a dependent stage a CompletionException wrapping the malformed cause")
|
||||
public void testExtractAsyncWrapsMalformedCause() throws Exception {
|
||||
var zip = zip(Map.of("readme.txt", CIPHERTEXT)); // no vault config
|
||||
|
||||
// this wrapping is load-bearing: extractAndShowImportTemplateWindow unwraps exactly one layer to decide
|
||||
// between the malformed dialog and the generic error window
|
||||
var seen = inTest.extractAsync(zip) //
|
||||
.handle((template, throwable) -> throwable) //
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
|
||||
Assertions.assertInstanceOf(CompletionException.class, seen);
|
||||
Assertions.assertInstanceOf(MalformedTemplateException.class, seen.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a malformed template is reported by the malformed-template dialog")
|
||||
public void testMalformedTemplateShowsDialog() throws Exception {
|
||||
var zip = zip(Map.of("readme.txt", CIPHERTEXT)); // no vault config
|
||||
doReturn(primaryStage).when(inTest).showMalformedTemplateDialog();
|
||||
|
||||
var result = inTest.extractAndShowImportTemplateWindow("MyVault", zip).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
|
||||
verify(inTest).showMalformedTemplateDialog();
|
||||
Assertions.assertEquals(primaryStage, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a failure that is not the template's fault does not claim the template is malformed")
|
||||
public void testEnvironmentalFailureIsNotReportedAsMalformed() {
|
||||
var cause = new IOException("no space left on device");
|
||||
doReturn(CompletableFuture.failedFuture(new CompletionException(cause))).when(inTest).extractAsync(org.mockito.ArgumentMatchers.any());
|
||||
|
||||
var future = inTest.extractAndShowImportTemplateWindow("MyVault", new byte[0]).toCompletableFuture();
|
||||
|
||||
var thrown = Assertions.assertThrows(ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS));
|
||||
Assertions.assertEquals(cause, thrown.getCause());
|
||||
verify(inTest, never()).showMalformedTemplateDialog();
|
||||
}
|
||||
|
||||
private static byte[] hubVaultConfig() {
|
||||
var header = """
|
||||
{"kid":"hub+https://hub.example.com/api/vaults/123","typ":"JWT","alg":"HS256",\
|
||||
"hub":{"clientId":"cryptomator","authEndpoint":"https://login.example.com/auth",\
|
||||
"tokenEndpoint":"https://login.example.com/token",\
|
||||
"authSuccessUrl":"https://hub.example.com/app/unlock-success",\
|
||||
"authErrorUrl":"https://hub.example.com/app/unlock-error",\
|
||||
"apiBaseUrl":"%s"}}""".formatted(API_BASE_URL);
|
||||
var payload = "{\"format\":8,\"cipherCombo\":\"SIV_GCM\",\"shorteningThreshold\":220}";
|
||||
var encoder = Base64.getUrlEncoder().withoutPadding();
|
||||
var token = encoder.encodeToString(header.getBytes(StandardCharsets.UTF_8)) //
|
||||
+ "." + encoder.encodeToString(payload.getBytes(StandardCharsets.UTF_8)) //
|
||||
+ "." + encoder.encodeToString("signature".getBytes(StandardCharsets.UTF_8));
|
||||
return token.getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static byte[] zip(Map<String, byte[]> entries) throws IOException {
|
||||
var ordered = new LinkedHashMap<>(entries);
|
||||
var baos = new ByteArrayOutputStream();
|
||||
try (var zos = new ZipOutputStream(baos)) {
|
||||
for (var entry : ordered.entrySet()) {
|
||||
zos.putNextEntry(new ZipEntry(entry.getKey()));
|
||||
zos.write(entry.getValue());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.cryptomator.ui.importtemplate;
|
||||
|
||||
import org.cryptomator.ui.importtemplate.VaultTemplateExtractor.MalformedTemplateException;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -9,7 +8,6 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -23,44 +21,31 @@ public class VaultTemplateExtractorTest {
|
||||
private static final byte[] CIPHERTEXT = "some-ciphertext".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
@DisplayName("a vault at the archive root is moved to the destination")
|
||||
@DisplayName("a vault at the archive root is unpacked and located")
|
||||
public void testVaultAtArchiveRoot(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(Map.of( //
|
||||
"vault.cryptomator", CONFIG, //
|
||||
"d/AB/CDEF/0.c9r", CIPHERTEXT //
|
||||
));
|
||||
var destination = tmp.resolve("MyVault");
|
||||
|
||||
var result = VaultTemplateExtractor.extractAndMove(zip, destination);
|
||||
var vaultRoot = VaultTemplateExtractor.extractTo(zip, tmp);
|
||||
|
||||
Assertions.assertEquals(destination, result);
|
||||
Assertions.assertArrayEquals(CONFIG, Files.readAllBytes(destination.resolve("vault.cryptomator")));
|
||||
Assertions.assertArrayEquals(CIPHERTEXT, Files.readAllBytes(destination.resolve("d/AB/CDEF/0.c9r")));
|
||||
Assertions.assertArrayEquals(CONFIG, Files.readAllBytes(vaultRoot.resolve("vault.cryptomator")));
|
||||
Assertions.assertArrayEquals(CIPHERTEXT, Files.readAllBytes(vaultRoot.resolve("d/AB/CDEF/0.c9r")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a vault nested in a single top-level folder is moved to the destination")
|
||||
@DisplayName("a vault nested in a single top-level folder is located")
|
||||
public void testVaultInSubfolder(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(Map.of( //
|
||||
"TemplateVault/vault.cryptomator", CONFIG, //
|
||||
"TemplateVault/d/AB/CDEF/0.c9r", CIPHERTEXT //
|
||||
));
|
||||
var destination = tmp.resolve("MyVault");
|
||||
|
||||
VaultTemplateExtractor.extractAndMove(zip, destination);
|
||||
var vaultRoot = VaultTemplateExtractor.extractTo(zip, tmp);
|
||||
|
||||
Assertions.assertArrayEquals(CONFIG, Files.readAllBytes(destination.resolve("vault.cryptomator")));
|
||||
Assertions.assertArrayEquals(CIPHERTEXT, Files.readAllBytes(destination.resolve("d/AB/CDEF/0.c9r")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an existing destination is not overwritten")
|
||||
public void testDestinationExists(@TempDir Path tmp) throws IOException {
|
||||
var destination = tmp.resolve("MyVault");
|
||||
Files.createDirectory(destination);
|
||||
var zip = zip(Map.of("vault.cryptomator", CONFIG));
|
||||
|
||||
Assertions.assertThrows(FileAlreadyExistsException.class, () -> VaultTemplateExtractor.extractAndMove(zip, destination));
|
||||
Assertions.assertEquals("TemplateVault", vaultRoot.getFileName().toString());
|
||||
Assertions.assertArrayEquals(CONFIG, Files.readAllBytes(vaultRoot.resolve("vault.cryptomator")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,7 +53,7 @@ public class VaultTemplateExtractorTest {
|
||||
public void testNotAZip(@TempDir Path tmp) {
|
||||
var notAZip = "this is not a zip archive".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplateExtractor.extractAndMove(notAZip, tmp.resolve("MyVault")));
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplateExtractor.extractTo(notAZip, tmp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -76,7 +61,7 @@ public class VaultTemplateExtractorTest {
|
||||
public void testNoVaultConfig(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(Map.of("readme.txt", CONFIG));
|
||||
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplateExtractor.extractAndMove(zip, tmp.resolve("MyVault")));
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplateExtractor.extractTo(zip, tmp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -87,7 +72,7 @@ public class VaultTemplateExtractorTest {
|
||||
"nested/vault.cryptomator", CONFIG //
|
||||
));
|
||||
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplateExtractor.extractAndMove(zip, tmp.resolve("MyVault")));
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplateExtractor.extractTo(zip, tmp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -99,18 +84,17 @@ public class VaultTemplateExtractorTest {
|
||||
"d/AB/CDEF/0.c9r", big //
|
||||
));
|
||||
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplateExtractor.extractAndMove(zip, tmp.resolve("MyVault")));
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplateExtractor.extractTo(zip, tmp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an archive with exactly the maximum number of entries is accepted")
|
||||
public void testAtEntryLimit(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(flatEntries(VaultTemplateExtractor.MAX_ENTRIES));
|
||||
var destination = tmp.resolve("MyVault");
|
||||
|
||||
VaultTemplateExtractor.extractAndMove(zip, destination);
|
||||
var vaultRoot = VaultTemplateExtractor.extractTo(zip, tmp);
|
||||
|
||||
Assertions.assertArrayEquals(CONFIG, Files.readAllBytes(destination.resolve("vault.cryptomator")));
|
||||
Assertions.assertArrayEquals(CONFIG, Files.readAllBytes(vaultRoot.resolve("vault.cryptomator")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -118,7 +102,26 @@ public class VaultTemplateExtractorTest {
|
||||
public void testExceedsEntryLimit(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(flatEntries(VaultTemplateExtractor.MAX_ENTRIES + 1));
|
||||
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplateExtractor.extractAndMove(zip, tmp.resolve("MyVault")));
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplateExtractor.extractTo(zip, tmp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a zip-slip entry does not escape the extraction directory")
|
||||
public void testZipSlip(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(Map.of( //
|
||||
"vault.cryptomator", CONFIG, //
|
||||
"../escaped.txt", CIPHERTEXT //
|
||||
));
|
||||
var targetDir = tmp.resolve("nested");
|
||||
Files.createDirectory(targetDir);
|
||||
|
||||
try {
|
||||
VaultTemplateExtractor.extractTo(zip, targetDir);
|
||||
} catch (IOException e) {
|
||||
// acceptable: extraction refused the malicious entry
|
||||
}
|
||||
|
||||
Assertions.assertTrue(Files.notExists(tmp.resolve("escaped.txt")), "entry escaped the extraction directory");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,25 +136,6 @@ public class VaultTemplateExtractorTest {
|
||||
return entries;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a zip-slip entry does not escape the destination")
|
||||
public void testZipSlip(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(Map.of( //
|
||||
"vault.cryptomator", CONFIG, //
|
||||
"../escaped.txt", CIPHERTEXT //
|
||||
));
|
||||
var destination = tmp.resolve("nested").resolve("MyVault");
|
||||
|
||||
try {
|
||||
VaultTemplateExtractor.extractAndMove(zip, destination);
|
||||
} catch (IOException e) {
|
||||
// acceptable: extraction refused the malicious entry
|
||||
}
|
||||
|
||||
Assertions.assertTrue(Files.notExists(tmp.resolve("nested").resolve("escaped.txt")), "entry escaped the destination directory");
|
||||
Assertions.assertTrue(Files.notExists(tmp.resolve("escaped.txt")), "entry escaped the temp tree");
|
||||
}
|
||||
|
||||
private static byte[] zip(Map<String, byte[]> entries) throws IOException {
|
||||
var ordered = new LinkedHashMap<>(entries);
|
||||
var baos = new ByteArrayOutputStream();
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package org.cryptomator.ui.importtemplate;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
public class VaultTemplateTest {
|
||||
|
||||
private static final byte[] CIPHERTEXT = "some-ciphertext".getBytes(StandardCharsets.UTF_8);
|
||||
private static final String API_BASE_URL = "https://hub.example.com/api";
|
||||
|
||||
@Test
|
||||
@DisplayName("a hub template exposes the hub it belongs to")
|
||||
public void testHubUrl() throws IOException {
|
||||
var zip = zip(Map.of("vault.cryptomator", hubVaultConfig(API_BASE_URL)));
|
||||
|
||||
try (var inTest = VaultTemplate.extract(zip)) {
|
||||
Assertions.assertEquals(API_BASE_URL + "/", inTest.hubUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a template whose config declares no hub has no hub url")
|
||||
public void testNoHubHeader() throws IOException {
|
||||
var zip = zip(Map.of("vault.cryptomator", vaultConfig(null)));
|
||||
|
||||
try (var inTest = VaultTemplate.extract(zip)) {
|
||||
Assertions.assertNull(inTest.hubUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an archive whose vault config is not a decodable token is rejected")
|
||||
public void testUndecodableVaultConfig() throws IOException {
|
||||
var zip = zip(Map.of("vault.cryptomator", "not-a-jwt".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplate.extract(zip));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("data that is not a zip is rejected")
|
||||
public void testNotAZip() {
|
||||
var notAZip = "this is not a zip archive".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplate.extract(notAZip));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the unpacked vault is moved to the destination")
|
||||
public void testMoveTo(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(Map.of( //
|
||||
"vault.cryptomator", hubVaultConfig(API_BASE_URL), //
|
||||
"d/AB/CDEF/0.c9r", CIPHERTEXT //
|
||||
));
|
||||
var destination = tmp.resolve("MyVault");
|
||||
|
||||
try (var inTest = VaultTemplate.extract(zip)) {
|
||||
inTest.moveTo(destination);
|
||||
}
|
||||
|
||||
Assertions.assertArrayEquals(CIPHERTEXT, Files.readAllBytes(destination.resolve("d/AB/CDEF/0.c9r")));
|
||||
Assertions.assertTrue(Files.exists(destination.resolve("vault.cryptomator")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an existing destination is not overwritten")
|
||||
public void testDestinationExists(@TempDir Path tmp) throws IOException {
|
||||
var destination = tmp.resolve("MyVault");
|
||||
Files.createDirectory(destination);
|
||||
var zip = zip(Map.of("vault.cryptomator", hubVaultConfig(API_BASE_URL)));
|
||||
|
||||
try (var inTest = VaultTemplate.extract(zip)) {
|
||||
Assertions.assertThrows(FileAlreadyExistsException.class, () -> inTest.moveTo(destination));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("closing discards the temporary directory, also after a completed import")
|
||||
public void testCloseCleansUpAfterMove(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(Map.of( //
|
||||
"vault.cryptomator", hubVaultConfig(API_BASE_URL), //
|
||||
"d/AB/CDEF/0.c9r", CIPHERTEXT //
|
||||
));
|
||||
var workDir = Files.createDirectory(tmp.resolve("work"));
|
||||
var destination = tmp.resolve("MyVault");
|
||||
|
||||
try (var inTest = VaultTemplate.extract(zip, workDir)) {
|
||||
Assertions.assertFalse(isEmpty(workDir), "sanity: the temporary directory should exist before close()");
|
||||
inTest.moveTo(destination);
|
||||
}
|
||||
|
||||
Assertions.assertTrue(isEmpty(workDir), "temporary extraction directory was not cleaned up");
|
||||
Assertions.assertTrue(Files.exists(destination.resolve("vault.cryptomator")), "the moved vault must survive close()");
|
||||
Assertions.assertArrayEquals(CIPHERTEXT, Files.readAllBytes(destination.resolve("d/AB/CDEF/0.c9r")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("closing discards the temporary directory a nested vault leaves behind")
|
||||
public void testCloseCleansUpNestedRemainder(@TempDir Path tmp) throws IOException {
|
||||
// a vault at the archive root IS the temporary directory, so moveTo relocates it wholesale; a nested vault
|
||||
// leaves its enclosing temporary directory behind, and only close() removes that
|
||||
var zip = zip(Map.of("TemplateVault/vault.cryptomator", hubVaultConfig(API_BASE_URL)));
|
||||
var workDir = Files.createDirectory(tmp.resolve("work"));
|
||||
var destination = tmp.resolve("MyVault");
|
||||
|
||||
try (var inTest = VaultTemplate.extract(zip, workDir)) {
|
||||
inTest.moveTo(destination);
|
||||
Assertions.assertFalse(isEmpty(workDir), "the enclosing temporary directory should outlive the move");
|
||||
}
|
||||
|
||||
Assertions.assertTrue(isEmpty(workDir), "temporary extraction directory was not cleaned up");
|
||||
Assertions.assertTrue(Files.exists(destination.resolve("vault.cryptomator")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("closing without importing discards the temporary directory")
|
||||
public void testCloseCleansUpWithoutMove(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(Map.of("vault.cryptomator", hubVaultConfig(API_BASE_URL)));
|
||||
var workDir = Files.createDirectory(tmp.resolve("work"));
|
||||
|
||||
try (var _ = VaultTemplate.extract(zip, workDir)) {
|
||||
Assertions.assertFalse(isEmpty(workDir), "sanity: the temporary directory should exist before close()");
|
||||
}
|
||||
|
||||
Assertions.assertTrue(isEmpty(workDir), "temporary extraction directory was not cleaned up");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a rejected template leaves no temporary directory behind")
|
||||
public void testNoTempDirLeakOnFailure(@TempDir Path tmp) throws IOException {
|
||||
var zip = zip(Map.of("readme.txt", CIPHERTEXT)); // no vault config
|
||||
var workDir = Files.createDirectory(tmp.resolve("work"));
|
||||
|
||||
Assertions.assertThrows(MalformedTemplateException.class, () -> VaultTemplate.extract(zip, workDir));
|
||||
|
||||
Assertions.assertTrue(isEmpty(workDir), "a failed extraction leaked a temporary directory");
|
||||
}
|
||||
|
||||
private static boolean isEmpty(Path dir) throws IOException {
|
||||
try (var children = Files.list(dir)) {
|
||||
return children.findAny().isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] hubVaultConfig(String apiBaseUrl) {
|
||||
return vaultConfig("""
|
||||
,"hub":{"clientId":"cryptomator","authEndpoint":"https://login.example.com/auth",\
|
||||
"tokenEndpoint":"https://login.example.com/token",\
|
||||
"authSuccessUrl":"https://hub.example.com/app/unlock-success",\
|
||||
"authErrorUrl":"https://hub.example.com/app/unlock-error",\
|
||||
"apiBaseUrl":"%s"}""".formatted(apiBaseUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a vault config token. The signature is not verifiable without the masterkey, which is exactly the state
|
||||
* the import dialog operates in - so a dummy signature is sufficient here.
|
||||
*/
|
||||
private static byte[] vaultConfig(String extraHeaderFields) {
|
||||
var header = "{\"kid\":\"hub+https://hub.example.com/api/vaults/123\",\"typ\":\"JWT\",\"alg\":\"HS256\"%s}" //
|
||||
.formatted(extraHeaderFields == null ? "" : extraHeaderFields);
|
||||
var payload = "{\"format\":8,\"cipherCombo\":\"SIV_GCM\",\"shorteningThreshold\":220}";
|
||||
var encoder = Base64.getUrlEncoder().withoutPadding();
|
||||
var token = encoder.encodeToString(header.getBytes(StandardCharsets.UTF_8)) //
|
||||
+ "." + encoder.encodeToString(payload.getBytes(StandardCharsets.UTF_8)) //
|
||||
+ "." + encoder.encodeToString("signature".getBytes(StandardCharsets.UTF_8));
|
||||
return token.getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static byte[] zip(Map<String, byte[]> entries) throws IOException {
|
||||
var ordered = new LinkedHashMap<>(entries);
|
||||
var baos = new ByteArrayOutputStream();
|
||||
try (var zos = new ZipOutputStream(baos)) {
|
||||
for (var entry : ordered.entrySet()) {
|
||||
zos.putNextEntry(new ZipEntry(entry.getKey()));
|
||||
zos.write(entry.getValue());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user