diff --git a/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java b/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java index 582bf27dd..c3b53f7e1 100644 --- a/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java +++ b/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java @@ -10,6 +10,8 @@ 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.lock.LockComponent; import org.cryptomator.ui.mainwindow.MainWindowComponent; import org.cryptomator.ui.notification.NotificationComponent; @@ -38,6 +40,7 @@ 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; @@ -148,12 +151,51 @@ public class FxApplicationWindows { CompletableFuture.runAsync(() -> shareVaultWindow.create(vault).showShareVaultWindow(), Platform::runLater); } + /** + * 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. + */ public CompletionStage showImportTemplateWindow(String name, byte[] template) { - return showMainWindow().thenApplyAsync(_ -> { - var component = importTemplateWindow.create(name, template); - component.showImportTemplateWindow(); - return component.window(); - }, Platform::runLater).whenComplete(this::reportErrors); + 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; } public CompletionStage showVaultOptionsWindow(Vault vault, SelectedVaultOptionsTab tab) { diff --git a/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateComponent.java b/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateComponent.java index e2b5d1c02..6b799f6a5 100644 --- a/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateComponent.java +++ b/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateComponent.java @@ -28,7 +28,7 @@ public interface ImportTemplateComponent { @Subcomponent.Factory interface Factory { - ImportTemplateComponent create(@BindsInstance @Named("vaultName") String name, @BindsInstance @Named("vaultTemplate") byte[] template); + ImportTemplateComponent create(@BindsInstance @Named("vaultName") String name, @BindsInstance VaultTemplate template); } } diff --git a/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateLocationController.java b/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateLocationController.java index 7feb69548..1983876d5 100644 --- a/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateLocationController.java +++ b/src/main/java/org/cryptomator/ui/importtemplate/ImportTemplateLocationController.java @@ -12,7 +12,6 @@ import org.cryptomator.ui.common.FxmlFile; import org.cryptomator.ui.common.FxmlScene; import org.cryptomator.ui.common.Tasks; import org.cryptomator.ui.controls.FontAwesome5IconView; -import org.cryptomator.ui.dialogs.Dialogs; import org.cryptomator.ui.fxapp.FxApplicationWindows; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,6 +46,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.util.Objects; import java.util.Optional; import java.util.ResourceBundle; import java.util.concurrent.ExecutorService; @@ -60,7 +60,7 @@ public class ImportTemplateLocationController implements FxController { private final Stage window; private final String vaultName; - private final byte[] template; + private final VaultTemplate template; private final ObjectProperty vaultPath; private final ObjectProperty vault; private final Lazy successScene; @@ -69,7 +69,6 @@ public class ImportTemplateLocationController implements FxController { private final ExecutorService executor; private final Settings settings; private final ResourceBundle resourceBundle; - private final Dialogs dialogs; private final ObservableValue vaultPathStatus; private final ObservableValue validVaultPath; private final BooleanProperty usePresetPath; @@ -80,6 +79,8 @@ public class ImportTemplateLocationController implements FxController { private final ObservableList radioButtons; private final ObservableList sortedRadioButtons; + private final String hubUrl; + private Path customVaultPath = DEFAULT_CUSTOM_VAULT_PATH; //FXML @@ -94,7 +95,7 @@ public class ImportTemplateLocationController implements FxController { @Inject ImportTemplateLocationController(@ImportTemplateWindow Stage window, // @Named("vaultName") String vaultName, // - @Named("vaultTemplate") byte[] template, // + VaultTemplate template, // ObjectProperty vaultPath, // @ImportTemplateWindow ObjectProperty vault, // @FxmlScene(FxmlFile.IMPORT_TEMPLATE_SUCCESS) Lazy successScene, // @@ -102,8 +103,7 @@ public class ImportTemplateLocationController implements FxController { VaultListManager vaultListManager, // ExecutorService executor, // Settings settings, // - ResourceBundle resourceBundle, // - Dialogs dialogs) { + ResourceBundle resourceBundle) { this.window = window; this.vaultName = vaultName; this.template = template; @@ -115,7 +115,7 @@ public class ImportTemplateLocationController implements FxController { this.executor = executor; this.settings = settings; this.resourceBundle = resourceBundle; - this.dialogs = dialogs; + this.hubUrl = Objects.requireNonNullElseGet(template.hubUrl(), () -> resourceBundle.getString("importTemplate.hubUrl.none")); this.vaultPathStatus = ObservableUtil.mapWithDefault(vaultPath, this::validatePath, new VaultPathStatus(false, "error.message")); this.validVaultPath = ObservableUtil.mapWithDefault(vaultPathStatus, VaultPathStatus::valid, false); this.vaultPathStatus.addListener(this::updateStatusLabel); @@ -183,7 +183,10 @@ public class ImportTemplateLocationController implements FxController { @FXML public void initialize() { var task = executor.submit(this::loadLocationPresets); - window.addEventHandler(WindowEvent.WINDOW_HIDING, _ -> task.cancel(true)); + window.addEventHandler(WindowEvent.WINDOW_HIDING, _ -> { + task.cancel(true); + template.close(); //discards the temporary extraction directory, whether or not the import completed + }); locationPresetsToggler.selectedToggleProperty().addListener(this::togglePredefinedLocation); usePresetPath.bind(locationPresetsToggler.selectedToggleProperty().isNotEqualTo(customRadioButton)); radioButtons.add(customLocationRadioBtn); @@ -248,19 +251,15 @@ public class ImportTemplateLocationController implements FxController { } Path destination = vaultPath.get(); processing.set(true); + // the template was already unpacked and validated when the deeplink arrived, so only the destination can still + // fail here - which the location picker has just checked, leaving races and hardware faults Tasks.create(() -> { - VaultTemplateExtractor.extractAndMove(template, destination); + template.moveTo(destination); return vaultListManager.add(destination); }).onSuccess(newVault -> { vault.set(newVault); rememberParentDirectory(destination); window.setScene(successScene.get()); - }).onError(VaultTemplateExtractor.MalformedTemplateException.class, e -> { // must precede the IOException handler: Tasks picks the first matching one - LOG.error("Vault template is malformed.", e); - dialogs.prepareMalformedTemplateDialog(window).setOkAction(stage -> { - stage.close(); - window.close(); //TODO: before showing this dialog, close the window and use the mainWindow as owner - }).build().showAndWait(); }).onError(IOException.class, e -> { LOG.error("Failed to import vault template.", e); appWindows.showErrorWindow(e, window, window.getScene()); @@ -286,6 +285,10 @@ public class ImportTemplateLocationController implements FxController { return vaultName; } + public String getHubUrl() { + return hubUrl; + } + public Path getVaultPath() { return vaultPath.get(); } diff --git a/src/main/java/org/cryptomator/ui/importtemplate/MalformedTemplateException.java b/src/main/java/org/cryptomator/ui/importtemplate/MalformedTemplateException.java new file mode 100644 index 000000000..bc51d3a64 --- /dev/null +++ b/src/main/java/org/cryptomator/ui/importtemplate/MalformedTemplateException.java @@ -0,0 +1,23 @@ +package org.cryptomator.ui.importtemplate; + +import java.io.IOException; + +/** + * Indicates that a vault template cannot be imported because the archive itself is unusable - as opposed to an + * {@link IOException} arising from the destination (already exists, not writable, ...). + *

+ * Callers distinguish the two to decide what to show the user: a malformed template is a dead end, whereas a + * destination problem is recoverable by picking a different location. + */ +public class MalformedTemplateException extends IOException { + + private static final long serialVersionUID = 1L; + + public MalformedTemplateException(String message) { + super(message); + } + + public MalformedTemplateException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplate.java b/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplate.java new file mode 100644 index 000000000..a0600671a --- /dev/null +++ b/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplate.java @@ -0,0 +1,150 @@ +package org.cryptomator.ui.importtemplate; + +import org.cryptomator.common.Constants; +import org.cryptomator.cryptofs.VaultConfig; +import org.cryptomator.cryptofs.VaultConfigLoadException; +import org.cryptomator.ui.keyloading.hub.HubConfig; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; + +/** + * A vault template that has been unpacked to a temporary directory and found sound. + *

+ * Holding an instance means the template can be imported: it was a readable ZIP within the entry and size + * limits, held exactly one decodable {@value Constants#VAULTCONFIG_FILENAME}, and no entry escaped the extraction + * directory. Unpacking therefore happens once, when the deeplink arrives, so a template that could never be imported is + * rejected before the user is asked to choose a storage location - and {@link #moveTo(Path)} is left with nothing to + * validate but the destination. + *

+ * The instance owns a temporary directory and must be {@link #close() closed}, whether or not the + * import completes. + *

+ * {@link #hubUrl()} is read from the unverified vault config: its signature is keyed on the masterkey, which is + * only obtained by completing the Hub flow. It is shown so the user can recognize an unexpected Hub; the authoritative + * trust decision remains with {@code CheckHostTrustController} at unlock time. + */ +public final class VaultTemplate implements AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(VaultTemplate.class); + + private final Path tempDir; + private final Path vaultRoot; + private final @Nullable String hubUrl; + + private VaultTemplate(Path tempDir, Path vaultRoot, @Nullable String hubUrl) { + this.tempDir = tempDir; + this.vaultRoot = vaultRoot; + 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. + *

+ * This touches the file system and should not run on the FX thread. + * + * @param archive the ZIP archive bytes + * @return the unpacked template, which the caller must close + * @throws MalformedTemplateException if the archive is unusable + * @throws IOException if unpacking fails + */ + public static VaultTemplate extract(byte[] archive) throws IOException { + return extract(archive, null); + } + + @VisibleForTesting + static VaultTemplate extract(byte[] archive, @Nullable Path tempParent) throws IOException { + Path tempDir = tempParent == null // + ? Files.createTempDirectory("vault-template-") // + : Files.createTempDirectory(tempParent, "vault-template-"); + try { + var vaultRoot = VaultTemplateExtractor.extractTo(archive, tempDir); + return new VaultTemplate(tempDir, vaultRoot, readHubUrl(vaultRoot)); + } catch (IOException | RuntimeException e) { + VaultTemplateExtractor.deleteQuietly(tempDir); + throw e; + } + } + + private static @Nullable String readHubUrl(Path vaultRoot) throws IOException { + var token = Files.readString(vaultRoot.resolve(Constants.VAULTCONFIG_FILENAME), StandardCharsets.US_ASCII).trim(); + HubConfig hubConfig; + try { + hubConfig = VaultConfig.decode(token).getHeader("hub", HubConfig.class); + } catch (VaultConfigLoadException e) { + throw new MalformedTemplateException("Template does not contain a decodable vault config.", e); + } + if (hubConfig == null) { + return null; // not a hub vault - allowed, the user just cannot be shown a Hub + } + try { + return hubConfig.getApiBaseUrl().toString(); + } catch (RuntimeException e) { + // hub header present but unusable (e.g. neither apiBaseUrl nor devicesResourceUrl set) + LOG.warn("Vault template declares an unusable hub config.", e); + return null; + } + } + + /** + * The Hub this template's vault belongs to, or {@code null} if its config declares none. + *

+ * Unverified - see the class documentation. + */ + public @Nullable String hubUrl() { + return hubUrl; + } + + /** + * Moves the unpacked vault to its final location. + * + * @param destination the target vault directory, which must not yet exist + * @throws java.nio.file.FileAlreadyExistsException if {@code destination} already exists + * @throws IOException if the vault cannot be moved + */ + public void moveTo(Path destination) throws IOException { + VaultTemplateExtractor.moveToDestination(vaultRoot, destination); + } + + /** + * Deletes the temporary directory. Safe to call after a successful {@link #moveTo(Path)}, which only moves the + * vault directory out of it. + */ + @Override + public void close() { + VaultTemplateExtractor.deleteQuietly(tempDir); + } +} diff --git a/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractor.java b/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractor.java index a7561de2d..2a5637398 100644 --- a/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractor.java +++ b/src/main/java/org/cryptomator/ui/importtemplate/VaultTemplateExtractor.java @@ -20,13 +20,15 @@ import java.util.stream.Stream; import java.util.zip.ZipException; /** - * Unpacks a vault template (a ZIP archive holding a ready-made vault) and moves the contained vault to a destination. + * Low-level mechanics of unpacking a vault template (a ZIP archive holding a ready-made vault). *

- * The archive is expanded into a temporary directory using the Java ZIP {@link FileSystem}, and the vault directory - * (identified by containing a {@value Constants#VAULTCONFIG_FILENAME} file) is then moved to the destination in a single - * step, so the vault only ever appears complete at its final location. + * The archive is expanded into a directory using the Java ZIP {@link FileSystem}, and the vault directory is located by + * finding the {@value Constants#VAULTCONFIG_FILENAME} it contains. Moving that directory to its final location is a + * separate step, so the vault only ever appears complete at its destination. + *

+ * Lifecycle of the temporary directory is owned by {@link VaultTemplate}, not by this class. */ -public final class VaultTemplateExtractor { +final class VaultTemplateExtractor { private static final Logger LOG = LoggerFactory.getLogger(VaultTemplateExtractor.class); // On Windows, URI template (base64url encoded) is is given on command line argument. Windows API restrict the length @@ -39,34 +41,41 @@ public final class VaultTemplateExtractor { } /** - * Unpacks the given template and moves the contained vault to {@code destination}. + * Unpacks the given template into {@code targetDir} and locates the vault it contains. * - * @param template the ZIP archive bytes - * @param destination the target vault directory, which must not yet exist - * @return {@code destination} - * @throws MalformedTemplateException if the template is not a valid zip or does not contain a vault - * @throws IOException if the destination already exists, or the - * files cannot be written or moved + * @param template the ZIP archive bytes + * @param targetDir an existing, empty directory to unpack into + * @return the unpacked vault directory below {@code targetDir} + * @throws MalformedTemplateException if the template is not a readable ZIP, exceeds the entry or size limits, or + * does not contain exactly one {@value Constants#VAULTCONFIG_FILENAME} + * @throws IOException if the files cannot be written */ - public static Path extractAndMove(byte[] template, Path destination) throws IOException { + static Path extractTo(byte[] template, Path targetDir) throws IOException { + Path tmpZip = Files.createTempFile("vault-template-", ".zip"); + try { + Files.write(tmpZip, template); + return unzip(tmpZip, targetDir); + } finally { + deleteQuietly(tmpZip); + } + } + + /** + * 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 tmpZip = Files.createTempFile("vault-template-", ".zip"); - Path tmpDir = Files.createTempDirectory("vault-template-"); - try { - Files.write(tmpZip, template); - Path vaultRoot = unzip(tmpZip, tmpDir); - Path parent = destination.getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - move(vaultRoot, destination); - return destination; - } finally { - deleteQuietly(tmpDir); - deleteQuietly(tmpZip); + Path parent = destination.getParent(); + if (parent != null) { + Files.createDirectories(parent); } + move(vaultRoot, destination); } private static Path unzip(Path zipFile, Path targetDir) throws IOException { @@ -183,7 +192,7 @@ public final class VaultTemplateExtractor { }); } - private static void deleteQuietly(Path path) { + static void deleteQuietly(Path path) { try { deleteRecursively(path); } catch (IOException e) { @@ -203,16 +212,4 @@ public final class VaultTemplateExtractor { } } - public static class MalformedTemplateException extends IOException { - private static final long serialVersionUID = 1L; - - public MalformedTemplateException(String message) { - super(message); - } - - public MalformedTemplateException(String message, Throwable cause) { - super(message, cause); - } - } - } diff --git a/src/main/resources/fxml/import_template_location.fxml b/src/main/resources/fxml/import_template_location.fxml index 03915875a..a580350d9 100644 --- a/src/main/resources/fxml/import_template_location.fxml +++ b/src/main/resources/fxml/import_template_location.fxml @@ -30,6 +30,11 @@ + + +