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:
Armin Schrenk
2026-07-21 16:22:52 +02:00
parent ad17f0b835
commit ae84f2f2fd
7 changed files with 519 additions and 141 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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