diff --git a/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java b/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java index c3b53f7e1..80b53bf12 100644 --- a/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java +++ b/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java @@ -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 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. - *

- * 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 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 showVaultOptionsWindow(Vault vault, SelectedVaultOptionsTab tab) { diff --git a/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateWindows.java b/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateWindows.java new file mode 100644 index 000000000..157aea313 --- /dev/null +++ b/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateWindows.java @@ -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. + *

+ * 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. + *

+ * 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 not 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 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[])} + *

+ * 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 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; + } + +} diff --git a/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplate.java b/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplate.java index a0600671a..5fea636d8 100644 --- a/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplate.java +++ b/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplate.java @@ -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. - *

- * 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 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. *

@@ -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. *

* Unverified - see the class documentation. */ diff --git a/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractor.java b/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractor.java index 2a5637398..df4741af1 100644 --- a/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractor.java +++ b/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractor.java @@ -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); diff --git a/src/test/java/org/cryptomator/ui/importtemplate/ImportTemplateWindowsTest.java b/src/test/java/org/cryptomator/ui/importtemplate/ImportTemplateWindowsTest.java new file mode 100644 index 000000000..89e1452eb --- /dev/null +++ b/src/test/java/org/cryptomator/ui/importtemplate/ImportTemplateWindowsTest.java @@ -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 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(); + } +} diff --git a/src/test/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractorTest.java b/src/test/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractorTest.java index ea6e2aa13..173c5567d 100644 --- a/src/test/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractorTest.java +++ b/src/test/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractorTest.java @@ -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 entries) throws IOException { var ordered = new LinkedHashMap<>(entries); var baos = new ByteArrayOutputStream(); diff --git a/src/test/java/org/cryptomator/ui/importtemplate/VaultTemplateTest.java b/src/test/java/org/cryptomator/ui/importtemplate/VaultTemplateTest.java new file mode 100644 index 000000000..7f3ece039 --- /dev/null +++ b/src/test/java/org/cryptomator/ui/importtemplate/VaultTemplateTest.java @@ -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 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(); + } +}