Merge pull request #2996 from cryptomator/feature/2856-folder-mounts-win

Improve handling of folder-mounts on Win
This commit is contained in:
JaniruTEC 2023-07-25 15:51:18 +02:00 committed by GitHub
commit 221b4e85bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 493 additions and 96 deletions

View File

@ -0,0 +1,17 @@
package org.cryptomator.common.mount;
import java.nio.file.Path;
public class HideawayNotDirectoryException extends IllegalMountPointException {
private final Path hideaway;
public HideawayNotDirectoryException(Path path, Path hideaway) {
super(path, "Existing hideaway (" + hideaway.toString() + ") for mountpoint is not a directory: " + path.toString());
this.hideaway = hideaway;
}
public Path getHideaway() {
return hideaway;
}
}

View File

@ -1,9 +1,25 @@
package org.cryptomator.common.mount;
import java.nio.file.Path;
/**
* Indicates that validation or preparation of a mountpoint failed due to a configuration error or an invalid system state.<br>
* Instances of this exception are usually caught and displayed to the user in an appropriate fashion, e.g. by {@link org.cryptomator.ui.unlock.UnlockInvalidMountPointController UnlockInvalidMountPointController.}
*/
public class IllegalMountPointException extends IllegalArgumentException {
public IllegalMountPointException(String msg) {
super(msg);
private final Path mountpoint;
public IllegalMountPointException(Path mountpoint) {
this(mountpoint, "The provided mountpoint has a problem: " + mountpoint.toString());
}
}
public IllegalMountPointException(Path mountpoint, String msg) {
super(msg);
this.mountpoint = mountpoint;
}
public Path getMountpoint() {
return mountpoint;
}
}

View File

@ -0,0 +1,10 @@
package org.cryptomator.common.mount;
import java.nio.file.Path;
public class MountPointCleanupFailedException extends IllegalMountPointException {
public MountPointCleanupFailedException(Path path) {
super(path, "Mountpoint could not be cleared: " + path.toString());
}
}

View File

@ -1,8 +1,10 @@
package org.cryptomator.common.mount;
import java.nio.file.Path;
public class MountPointInUseException extends IllegalMountPointException {
public MountPointInUseException(String msg) {
super(msg);
public MountPointInUseException(Path path) {
super(path);
}
}

View File

@ -0,0 +1,10 @@
package org.cryptomator.common.mount;
import java.nio.file.Path;
public class MountPointNotEmptyDirectoryException extends IllegalMountPointException {
public MountPointNotEmptyDirectoryException(Path path, String msg) {
super(path, msg);
}
}

View File

@ -0,0 +1,14 @@
package org.cryptomator.common.mount;
import java.nio.file.Path;
public class MountPointNotExistingException extends IllegalMountPointException {
public MountPointNotExistingException(Path path, String msg) {
super(path, msg);
}
public MountPointNotExistingException(Path path) {
super(path, "Mountpoint does not exist: " + path);
}
}

View File

@ -1,8 +0,0 @@
package org.cryptomator.common.mount;
public class MountPointNotExistsException extends IllegalMountPointException {
public MountPointNotExistsException(String msg) {
super(msg);
}
}

View File

@ -1,8 +1,10 @@
package org.cryptomator.common.mount;
import java.nio.file.Path;
public class MountPointNotSupportedException extends IllegalMountPointException {
public MountPointNotSupportedException(String msg) {
super(msg);
public MountPointNotSupportedException(Path path, String msg) {
super(path, msg);
}
}

View File

@ -1,12 +0,0 @@
package org.cryptomator.common.mount;
public class MountPointPreparationException extends RuntimeException {
public MountPointPreparationException(String msg) {
super(msg);
}
public MountPointPreparationException(Throwable cause) {
super(cause);
}
}

View File

@ -5,13 +5,11 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.NotDirectoryException;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
public final class MountWithinParentUtil {
@ -22,31 +20,33 @@ public final class MountWithinParentUtil {
private MountWithinParentUtil() {}
static void prepareParentNoMountPoint(Path mountPoint) throws MountPointPreparationException {
static void prepareParentNoMountPoint(Path mountPoint) throws IllegalMountPointException, IOException {
Path hideaway = getHideaway(mountPoint);
var mpExists = Files.exists(mountPoint, LinkOption.NOFOLLOW_LINKS);
var mpState = getMountPointState(mountPoint);
var hideExists = Files.exists(hideaway, LinkOption.NOFOLLOW_LINKS);
//TODO: possible improvement by just deleting an _empty_ hideaway
if (mpExists && hideExists) { //both resources exist (whatever type)
throw new MountPointPreparationException(new FileAlreadyExistsException(hideaway.toString()));
} else if (!mpExists && !hideExists) { //neither mountpoint nor hideaway exist
throw new MountPointPreparationException(new NoSuchFileException(mountPoint.toString()));
} else if (!mpExists) { //only hideaway exists
checkIsDirectory(hideaway);
LOG.info("Mountpoint {} seems to be not properly cleaned up. Will be fixed on unmount.", mountPoint);
try {
if (SystemUtils.IS_OS_WINDOWS) {
Files.setAttribute(hideaway, WIN_HIDDEN_ATTR, true, LinkOption.NOFOLLOW_LINKS);
}
} catch (IOException e) {
throw new MountPointPreparationException(e);
}
} else { //only mountpoint exists
try {
checkIsDirectory(mountPoint);
checkIsEmpty(mountPoint);
if (mpState == MountPointState.BROKEN_JUNCTION) {
LOG.info("Mountpoint \"{}\" is still a junction. Deleting it.", mountPoint);
Files.delete(mountPoint); //Throws if mountPoint is also a non-empty folder
mpState = MountPointState.NOT_EXISTING;
}
if (mpState == MountPointState.NOT_EXISTING && !hideExists) { //neither mountpoint nor hideaway exist
throw new MountPointNotExistingException(mountPoint);
} else if (mpState == MountPointState.NOT_EXISTING) { //only hideaway exists
checkIsHideawayDirectory(mountPoint, hideaway);
LOG.info("Mountpoint {} seems to be not properly cleaned up. Will be fixed on unmount.", mountPoint);
if (SystemUtils.IS_OS_WINDOWS) {
Files.setAttribute(hideaway, WIN_HIDDEN_ATTR, true, LinkOption.NOFOLLOW_LINKS);
}
} else {
assert mpState == MountPointState.EMPTY_DIR;
try {
if (hideExists) { //... with hideaway
removeResidualHideaway(mountPoint, hideaway);
}
//... (now) without hideaway
Files.move(mountPoint, hideaway);
if (SystemUtils.IS_OS_WINDOWS) {
Files.setAttribute(hideaway, WIN_HIDDEN_ATTR, true, LinkOption.NOFOLLOW_LINKS);
@ -54,30 +54,66 @@ public final class MountWithinParentUtil {
int attempts = 0;
while (!Files.notExists(mountPoint)) {
if (attempts >= 10) {
throw new MountPointPreparationException("Path " + mountPoint + " could not be cleared");
throw new MountPointCleanupFailedException(mountPoint);
}
Thread.sleep(1000);
attempts++;
}
} catch (IOException e) {
throw new MountPointPreparationException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new MountPointPreparationException(e);
throw new RuntimeException(e);
}
}
}
//visible for testing
static MountPointState getMountPointState(Path path) throws IOException, IllegalMountPointException {
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
return MountPointState.NOT_EXISTING;
}
if (!Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS).isOther()) {
checkIsMountPointDirectory(path);
checkIsMountPointEmpty(path);
return MountPointState.EMPTY_DIR;
}
if (Files.exists(path /* FOLLOW_LINKS */)) { //Both junction and target exist
throw new MountPointInUseException(path);
}
return MountPointState.BROKEN_JUNCTION;
}
//visible for testing
enum MountPointState {
NOT_EXISTING,
EMPTY_DIR,
BROKEN_JUNCTION;
}
//visible for testing
static void removeResidualHideaway(Path mountPoint, Path hideaway) throws IOException {
checkIsHideawayDirectory(mountPoint, hideaway);
Files.delete(hideaway); //Fails if not empty
}
static void cleanup(Path mountPoint) {
Path hideaway = getHideaway(mountPoint);
try {
waitForMountpointRestoration(mountPoint);
if (Files.notExists(hideaway, LinkOption.NOFOLLOW_LINKS)) {
LOG.error("Unable to restore hidden directory to mountpoint \"{}\": Directory does not exist.", mountPoint);
return;
}
Files.move(hideaway, mountPoint);
if (SystemUtils.IS_OS_WINDOWS) {
Files.setAttribute(mountPoint, WIN_HIDDEN_ATTR, false);
}
} catch (IOException e) {
LOG.error("Unable to restore hidden directory to mountpoint {}.", mountPoint, e);
LOG.error("Unable to restore hidden directory to mountpoint \"{}\".", mountPoint, e);
}
}
@ -99,16 +135,22 @@ public final class MountWithinParentUtil {
}
}
private static void checkIsDirectory(Path toCheck) throws MountPointPreparationException {
private static void checkIsMountPointDirectory(Path toCheck) throws IllegalMountPointException {
if (!Files.isDirectory(toCheck, LinkOption.NOFOLLOW_LINKS)) {
throw new MountPointPreparationException(new NotDirectoryException(toCheck.toString()));
throw new MountPointNotEmptyDirectoryException(toCheck, "Mountpoint is not a directory: " + toCheck);
}
}
private static void checkIsEmpty(Path toCheck) throws MountPointPreparationException, IOException {
private static void checkIsHideawayDirectory(Path mountPoint, Path hideawayToCheck) {
if (!Files.isDirectory(hideawayToCheck, LinkOption.NOFOLLOW_LINKS)) {
throw new HideawayNotDirectoryException(mountPoint, hideawayToCheck);
}
}
private static void checkIsMountPointEmpty(Path toCheck) throws IllegalMountPointException, IOException {
try (var dirStream = Files.list(toCheck)) {
if (dirStream.findFirst().isPresent()) {
throw new MountPointPreparationException(new DirectoryNotEmptyException(toCheck.toString()));
throw new MountPointNotEmptyDirectoryException(toCheck, "Mountpoint directory is not empty: " + toCheck);
}
}
}

View File

@ -99,7 +99,7 @@ public class Mounter {
var mpIsDriveLetter = userChosenMountPoint.toString().matches("[A-Z]:\\\\");
if (mpIsDriveLetter) {
if (driveLetters.getOccupied().contains(userChosenMountPoint)) {
throw new MountPointInUseException(userChosenMountPoint.toString());
throw new MountPointInUseException(userChosenMountPoint);
}
} else if (canMountToParent && !canMountToDir) {
MountWithinParentUtil.prepareParentNoMountPoint(userChosenMountPoint);
@ -115,13 +115,13 @@ public class Mounter {
|| (!canMountToParent && !mpIsDriveLetter) //
|| (!canMountToDir && !canMountToParent && !canMountToSystem && !canMountToDriveLetter);
if (configNotSupported) {
throw new MountPointNotSupportedException(e.getMessage());
throw new MountPointNotSupportedException(userChosenMountPoint, e.getMessage());
} else if (canMountToDir && !canMountToParent && !Files.exists(userChosenMountPoint)) {
//mountpoint must exist
throw new MountPointNotExistsException(e.getMessage());
throw new MountPointNotExistingException(userChosenMountPoint, e.getMessage());
} else {
//TODO: add specific exception for !canMountToDir && canMountToParent && !Files.notExists(userChosenMountPoint)
throw new IllegalMountPointException(e.getMessage());
throw new IllegalMountPointException(userChosenMountPoint, e.getMessage());
}
}
}

View File

@ -1,7 +1,12 @@
package org.cryptomator.ui.unlock;
import org.cryptomator.common.ObservableUtil;
import org.cryptomator.common.mount.HideawayNotDirectoryException;
import org.cryptomator.common.mount.IllegalMountPointException;
import org.cryptomator.common.mount.MountPointCleanupFailedException;
import org.cryptomator.common.mount.MountPointInUseException;
import org.cryptomator.common.mount.MountPointNotExistsException;
import org.cryptomator.common.mount.MountPointNotEmptyDirectoryException;
import org.cryptomator.common.mount.MountPointNotExistingException;
import org.cryptomator.common.mount.MountPointNotSupportedException;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.ui.common.FxController;
@ -9,12 +14,15 @@ import org.cryptomator.ui.controls.FormattedLabel;
import org.cryptomator.ui.fxapp.FxApplicationWindows;
import org.cryptomator.ui.preferences.SelectedPreferencesTab;
import org.cryptomator.ui.vaultoptions.SelectedVaultOptionsTab;
import org.jetbrains.annotations.PropertyKey;
import javax.inject.Inject;
import javafx.beans.property.ObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML;
import javafx.stage.Stage;
import java.nio.file.Path;
import java.util.ResourceBundle;
import java.util.concurrent.atomic.AtomicReference;
//At the current point in time only the CustomMountPointChooser may cause this window to be shown.
@UnlockScoped
@ -23,28 +31,31 @@ public class UnlockInvalidMountPointController implements FxController {
private final Stage window;
private final Vault vault;
private final FxApplicationWindows appWindows;
private final ResourceBundle resourceBundle;
private final ExceptionType exceptionType;
private final String exceptionMessage;
private final ObservableValue<ExceptionType> exceptionType;
private final ObservableValue<Path> exceptionPath;
private final ObservableValue<String> exceptionMessage;
private final ObservableValue<Path> hideawayPath;
private final ObservableValue<String> format;
private final ObservableValue<Boolean> showPreferences;
private final ObservableValue<Boolean> showVaultOptions;
public FormattedLabel dialogDescription;
@Inject
UnlockInvalidMountPointController(@UnlockWindow Stage window, @UnlockWindow Vault vault, @UnlockWindow AtomicReference<Throwable> unlockException, FxApplicationWindows appWindows, ResourceBundle resourceBundle) {
UnlockInvalidMountPointController(@UnlockWindow Stage window, @UnlockWindow Vault vault, @UnlockWindow ObjectProperty<IllegalMountPointException> illegalMountPointException, FxApplicationWindows appWindows, ResourceBundle resourceBundle) {
this.window = window;
this.vault = vault;
this.appWindows = appWindows;
this.resourceBundle = resourceBundle;
var exc = unlockException.get();
this.exceptionType = getExceptionType(exc);
this.exceptionMessage = exc.getMessage();
}
this.exceptionType = illegalMountPointException.map(this::getExceptionType);
this.exceptionPath = illegalMountPointException.map(IllegalMountPointException::getMountpoint);
this.exceptionMessage = illegalMountPointException.map(IllegalMountPointException::getMessage);
this.hideawayPath = illegalMountPointException.map(e -> e instanceof HideawayNotDirectoryException haeExc ? haeExc.getHideaway() : null);
@FXML
public void initialize() {
dialogDescription.setFormat(resourceBundle.getString(exceptionType.translationKey));
dialogDescription.setArg1(exceptionMessage);
this.format = ObservableUtil.mapWithDefault(exceptionType, type -> resourceBundle.getString(type.translationKey), "");
this.showPreferences = ObservableUtil.mapWithDefault(exceptionType, type -> type.action == ButtonAction.SHOW_PREFERENCES, false);
this.showVaultOptions = ObservableUtil.mapWithDefault(exceptionType, type -> type.action == ButtonAction.SHOW_VAULT_OPTIONS, false);
}
@FXML
@ -67,8 +78,11 @@ public class UnlockInvalidMountPointController implements FxController {
private ExceptionType getExceptionType(Throwable unlockException) {
return switch (unlockException) {
case MountPointNotSupportedException x -> ExceptionType.NOT_SUPPORTED;
case MountPointNotExistsException x -> ExceptionType.NOT_EXISTING;
case MountPointNotExistingException x -> ExceptionType.NOT_EXISTING;
case MountPointInUseException x -> ExceptionType.IN_USE;
case HideawayNotDirectoryException x -> ExceptionType.HIDEAWAY_NOT_DIR;
case MountPointCleanupFailedException x -> ExceptionType.COULD_NOT_BE_CLEARED;
case MountPointNotEmptyDirectoryException x -> ExceptionType.NOT_EMPTY_DIRECTORY;
default -> ExceptionType.GENERIC;
};
}
@ -78,12 +92,15 @@ public class UnlockInvalidMountPointController implements FxController {
NOT_SUPPORTED("unlock.error.customPath.description.notSupported", ButtonAction.SHOW_PREFERENCES),
NOT_EXISTING("unlock.error.customPath.description.notExists", ButtonAction.SHOW_VAULT_OPTIONS),
IN_USE("unlock.error.customPath.description.inUse", ButtonAction.SHOW_VAULT_OPTIONS),
HIDEAWAY_NOT_DIR("unlock.error.customPath.description.hideawayNotDir", ButtonAction.SHOW_VAULT_OPTIONS),
COULD_NOT_BE_CLEARED("unlock.error.customPath.description.couldNotBeCleaned", ButtonAction.SHOW_VAULT_OPTIONS),
NOT_EMPTY_DIRECTORY("unlock.error.customPath.description.notEmptyDir", ButtonAction.SHOW_VAULT_OPTIONS),
GENERIC("unlock.error.customPath.description.generic", ButtonAction.SHOW_PREFERENCES);
private final String translationKey;
private final ButtonAction action;
ExceptionType(String translationKey, ButtonAction action) {
ExceptionType(@PropertyKey(resourceBundle = "i18n.strings") String translationKey, ButtonAction action) {
this.translationKey = translationKey;
this.action = action;
}
@ -91,6 +108,7 @@ public class UnlockInvalidMountPointController implements FxController {
private enum ButtonAction {
//TODO Add option to show filesystem, e.g. for ExceptionType.HIDEAWAY_EXISTS
SHOW_PREFERENCES,
SHOW_VAULT_OPTIONS;
@ -98,11 +116,51 @@ public class UnlockInvalidMountPointController implements FxController {
/* Getter */
public boolean isShowPreferences() {
return exceptionType.action == ButtonAction.SHOW_PREFERENCES;
public Path getExceptionPath() {
return exceptionPath.getValue();
}
public boolean isShowVaultOptions() {
return exceptionType.action == ButtonAction.SHOW_VAULT_OPTIONS;
public ObservableValue<Path> exceptionPathProperty() {
return exceptionPath;
}
public String getFormat() {
return format.getValue();
}
public ObservableValue<String> formatProperty() {
return format;
}
public String getExceptionMessage() {
return exceptionMessage.getValue();
}
public ObservableValue<String> exceptionMessageProperty() {
return exceptionMessage;
}
public Path getHideawayPath() {
return hideawayPath.getValue();
}
public ObservableValue<Path> hideawayPathProperty() {
return hideawayPath;
}
public Boolean getShowPreferences() {
return showPreferences.getValue();
}
public ObservableValue<Boolean> showPreferencesProperty() {
return showPreferences;
}
public Boolean getShowVaultOptions() {
return showVaultOptions.getValue();
}
public ObservableValue<Boolean> showVaultOptionsProperty() {
return showVaultOptions;
}
}

View File

@ -4,6 +4,7 @@ import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import dagger.multibindings.IntoMap;
import org.cryptomator.common.mount.IllegalMountPointException;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.ui.common.DefaultSceneFactory;
import org.cryptomator.ui.common.FxController;
@ -18,12 +19,13 @@ import org.jetbrains.annotations.Nullable;
import javax.inject.Named;
import javax.inject.Provider;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Scene;
import javafx.stage.Modality;
import javafx.stage.Stage;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.concurrent.atomic.AtomicReference;
@Module(subcomponents = {KeyLoadingComponent.class})
abstract class UnlockModule {
@ -61,8 +63,8 @@ abstract class UnlockModule {
@Provides
@UnlockWindow
@UnlockScoped
static AtomicReference<Throwable> unlockException() {
return new AtomicReference<>();
static ObjectProperty<IllegalMountPointException> illegalMountPointException() {
return new SimpleObjectProperty<>();
}
@Provides

View File

@ -17,11 +17,11 @@ import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.concurrent.Task;
import javafx.scene.Scene;
import javafx.stage.Stage;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;
/**
* A multi-step task that consists of background activities as well as user interaction.
@ -40,10 +40,10 @@ public class UnlockWorkflow extends Task<Boolean> {
private final Lazy<Scene> invalidMountPointScene;
private final FxApplicationWindows appWindows;
private final KeyLoadingStrategy keyLoadingStrategy;
private final AtomicReference<Throwable> unlockFailedException;
private final ObjectProperty<IllegalMountPointException> illegalMountPointException;
@Inject
UnlockWorkflow(@UnlockWindow Stage window, @UnlockWindow Vault vault, VaultService vaultService, @FxmlScene(FxmlFile.UNLOCK_SUCCESS) Lazy<Scene> successScene, @FxmlScene(FxmlFile.UNLOCK_INVALID_MOUNT_POINT) Lazy<Scene> invalidMountPointScene, FxApplicationWindows appWindows, @UnlockWindow KeyLoadingStrategy keyLoadingStrategy, @UnlockWindow AtomicReference<Throwable> unlockFailedException) {
UnlockWorkflow(@UnlockWindow Stage window, @UnlockWindow Vault vault, VaultService vaultService, @FxmlScene(FxmlFile.UNLOCK_SUCCESS) Lazy<Scene> successScene, @FxmlScene(FxmlFile.UNLOCK_INVALID_MOUNT_POINT) Lazy<Scene> invalidMountPointScene, FxApplicationWindows appWindows, @UnlockWindow KeyLoadingStrategy keyLoadingStrategy, @UnlockWindow ObjectProperty<IllegalMountPointException> illegalMountPointException) {
this.window = window;
this.vault = vault;
this.vaultService = vaultService;
@ -51,7 +51,7 @@ public class UnlockWorkflow extends Task<Boolean> {
this.invalidMountPointScene = invalidMountPointScene;
this.appWindows = appWindows;
this.keyLoadingStrategy = keyLoadingStrategy;
this.unlockFailedException = unlockFailedException;
this.illegalMountPointException = illegalMountPointException;
}
@Override
@ -79,7 +79,7 @@ public class UnlockWorkflow extends Task<Boolean> {
private void handleIllegalMountPointError(IllegalMountPointException impe) {
Platform.runLater(() -> {
unlockFailedException.set(impe);
illegalMountPointException.set(impe);
window.setScene(invalidMountPointScene.get());
window.show();
});

View File

@ -40,7 +40,7 @@
</padding>
</Label>
<FormattedLabel fx:id="dialogDescription" wrapText="true" textAlignment="LEFT"/>
<FormattedLabel fx:id="dialogDescription" wrapText="true" textAlignment="LEFT" format="${controller.format}" arg1="${controller.exceptionPath}" arg2="${controller.exceptionMessage}" arg3="${controller.hideawayPath}"/>
<Region VBox.vgrow="ALWAYS" minHeight="18"/>
<ButtonBar buttonMinWidth="120" buttonOrder="+CI">

View File

@ -136,8 +136,11 @@ unlock.success.revealBtn=Reveal Drive
unlock.error.customPath.message=Unable to mount vault to custom path
unlock.error.customPath.description.notSupported=If you wish to keep using the custom path, please go to the preferences and select a volume type that supports it. Otherwise, go to the vault options and choose a supported mount point.
unlock.error.customPath.description.notExists=The custom mount path does not exist. Either create it in your local filesystem or change it in the vault options.
unlock.error.customPath.description.inUse=Drive letter "%s" is already in use.
unlock.error.customPath.description.generic=You have selected a custom mount path for this vault, but using it failed with the message: %s
unlock.error.customPath.description.inUse=The drive letter or custom mount path "%s" is already in use.
unlock.error.customPath.description.hideawayNotDir=The temporary, hidden file "%3$s" used for unlock could not be removed. Please check the file and then delete it manually.
unlock.error.customPath.description.couldNotBeCleaned=Your vault could not be mounted to the path "%s". Please try again or choose a different path.
unlock.error.customPath.description.notEmptyDir=The custom mount path "%s" is not an empty folder. Please choose an empty folder and try again.
unlock.error.customPath.description.generic=You have selected a custom mount path for this vault, but using it failed with the message: %2$s
## Hub
hub.noKeychain.message=Unable to access device key
hub.noKeychain.description=In order to unlock Hub vaults, a device key is required, which is secured using a keychain. To proceed, enable “%s” and select a keychain in the preferences.

View File

@ -0,0 +1,241 @@
package org.cryptomator.common.mount;
import org.apache.commons.lang3.SystemUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledOnOs;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.IOException;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
import static org.cryptomator.common.mount.MountWithinParentUtil.MountPointState.EMPTY_DIR;
import static org.cryptomator.common.mount.MountWithinParentUtil.MountPointState.NOT_EXISTING;
import static org.cryptomator.common.mount.MountWithinParentUtil.cleanup;
import static org.cryptomator.common.mount.MountWithinParentUtil.getHideaway;
import static org.cryptomator.common.mount.MountWithinParentUtil.getMountPointState;
import static org.cryptomator.common.mount.MountWithinParentUtil.prepareParentNoMountPoint;
import static org.cryptomator.common.mount.MountWithinParentUtil.removeResidualHideaway;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class MountWithinParentUtilTest {
@Test
void testPrepareNeitherExist(@TempDir Path parentDir) {
assertThrows(MountPointNotExistingException.class, () -> {
prepareParentNoMountPoint(parentDir.resolve("mount"));
});
}
@Test
void testPrepareOnlyHideawayFileExists(@TempDir Path parentDir) throws IOException {
var mount = parentDir.resolve("mount");
var hideaway = getHideaway(mount);
Files.createFile(hideaway);
assertThrows(HideawayNotDirectoryException.class, () -> {
prepareParentNoMountPoint(mount);
});
}
@Test
void testPrepareOnlyHideawayDirExists(@TempDir Path parentDir) throws IOException {
var mount = parentDir.resolve("mount");
var hideaway = getHideaway(mount);
Files.createDirectory(hideaway);
assertFalse(isHidden(hideaway));
prepareParentNoMountPoint(mount);
assumeTrue(SystemUtils.IS_OS_WINDOWS);
assertTrue(isHidden(hideaway));
}
@Test
void testPrepareBothExistHideawayFile(@TempDir Path parentDir) throws IOException {
var mount = parentDir.resolve("mount");
var hideaway = getHideaway(mount);
Files.createFile(hideaway);
Files.createDirectory(mount);
assertThrows(HideawayNotDirectoryException.class, () -> {
prepareParentNoMountPoint(mount);
});
}
@Test
void testPrepareBothExistHideawayNotEmpty(@TempDir Path parentDir) throws IOException {
var mount = parentDir.resolve("mount");
var hideaway = getHideaway(mount);
Files.createDirectory(hideaway);
Files.createFile(hideaway.resolve("dummy"));
Files.createDirectory(mount);
assertThrows(DirectoryNotEmptyException.class, () -> {
prepareParentNoMountPoint(mount);
});
}
@Test
void testPrepareBothExistMountNotDir(@TempDir Path parentDir) throws IOException {
var mount = parentDir.resolve("mount");
var hideaway = getHideaway(mount);
Files.createFile(hideaway);
Files.createFile(mount);
assertThrows(MountPointNotEmptyDirectoryException.class, () -> {
prepareParentNoMountPoint(mount); //Must not throw something related to hideaway
});
assertTrue(Files.exists(hideaway, NOFOLLOW_LINKS));
}
@Test
void testPrepareBothExistMountNotEmpty(@TempDir Path parentDir) throws IOException {
var mount = parentDir.resolve("mount");
var hideaway = getHideaway(mount);
Files.createFile(hideaway);
Files.createDirectory(mount);
Files.createFile(mount.resolve("dummy"));
assertThrows(MountPointNotEmptyDirectoryException.class, () -> {
prepareParentNoMountPoint(mount); //Must not throw something related to hideaway
});
assertTrue(Files.exists(hideaway, NOFOLLOW_LINKS));
}
@Test
void testPrepareBothExist(@TempDir Path parentDir) throws IOException {
var mount = parentDir.resolve("mount");
var hideaway = getHideaway(mount);
Files.createDirectory(hideaway);
Files.createDirectory(mount);
prepareParentNoMountPoint(mount);
assertTrue(Files.notExists(mount, NOFOLLOW_LINKS));
assumeTrue(SystemUtils.IS_OS_WINDOWS);
assertTrue(isHidden(hideaway));
}
@Test
void testHandleMountPointFolderDoesNotExist(@TempDir Path parentDir) throws IOException {
assertSame(getMountPointState(parentDir.resolve("notExisting")), NOT_EXISTING);
}
@Test
void testHandleMountPointFolderIsFile(@TempDir Path parentDir) throws IOException {
var regularFile = parentDir.resolve("regularFile");
Files.createFile(regularFile);
assertThrows(MountPointNotEmptyDirectoryException.class, () -> {
getMountPointState(regularFile);
});
}
@Test
void testHandleMountPointFolderIsNotEmpty(@TempDir Path parentDir) throws IOException {
var regularFolder = parentDir.resolve("regularFolder");
var dummyFile = regularFolder.resolve("dummy");
Files.createDirectory(regularFolder);
Files.createFile(dummyFile);
assertThrows(MountPointNotEmptyDirectoryException.class, () -> {
getMountPointState(regularFolder);
});
}
@Test
void testHandleMountPointFolder(@TempDir Path parentDir) throws IOException {
//Sadly can't easily create files with "Other" attribute
var regularFolder = parentDir.resolve("regularFolder");
Files.createDirectory(regularFolder);
assertSame(getMountPointState(regularFolder), EMPTY_DIR);
}
@Test
void testRemoveResidualHideawayFile(@TempDir Path parentDir) throws IOException {
var hideaway = parentDir.resolve("hideaway");
Files.createFile(hideaway);
assertThrows(HideawayNotDirectoryException.class, () -> removeResidualHideaway(parentDir.resolve("mount"), hideaway));
}
@Test
void testRemoveResidualHideawayNotEmpty(@TempDir Path parentDir) throws IOException {
var hideaway = parentDir.resolve("hideaway");
Files.createDirectory(hideaway);
Files.createFile(hideaway.resolve("dummy"));
assertThrows(DirectoryNotEmptyException.class, () -> removeResidualHideaway(parentDir.resolve("mount"), hideaway));
}
@Test
void testCleanupNoHideaway(@TempDir Path parentDir) {
assertDoesNotThrow(() -> cleanup(parentDir.resolve("mount")));
}
@Test
void testCleanup(@TempDir Path parentDir) throws IOException {
var mount = parentDir.resolve("mount");
var hideaway = getHideaway(mount);
Files.createDirectory(hideaway);
cleanup(mount);
assertTrue(Files.exists(mount, NOFOLLOW_LINKS));
assertTrue(Files.notExists(hideaway, NOFOLLOW_LINKS));
assertFalse(isHidden(mount));
}
@Test
@EnabledOnOs(OS.WINDOWS)
void testGetHideawayRootDirWin() {
var mount = Path.of("C:\\mount");
var hideaway = getHideaway(mount);
assertEquals(mount.getParent(), hideaway.getParent());
assertEquals(mount.getParent().resolve(".~$mount.tmp"), hideaway);
assertEquals(mount.getParent().toAbsolutePath() + ".~$mount.tmp", hideaway.toAbsolutePath().toString());
}
@Test
@DisabledOnOs(OS.WINDOWS)
void testGetHideawayRootDirUnix() {
var mount = Path.of("/mount");
var hideaway = getHideaway(mount);
assertEquals(mount.getParent(), hideaway.getParent());
assertEquals(mount.getParent().resolve(".~$mount.tmp"), hideaway);
assertEquals(mount.getParent().toAbsolutePath() + ".~$mount.tmp", hideaway.toAbsolutePath().toString());
}
@Test
void testGetHideaway(@TempDir Path parentDir) {
var mount = parentDir.resolve("mount");
var hideaway = getHideaway(mount);
assertEquals(mount.getParent(), hideaway.getParent());
assertEquals(mount.getParent().resolve(".~$mount.tmp"), hideaway);
assertEquals(mount.getParent().toAbsolutePath() + File.separator + ".~$mount.tmp", hideaway.toAbsolutePath().toString());
}
private static boolean isHidden(Path path) throws IOException {
try {
return (boolean) Objects.requireNonNullElse(Files.getAttribute(path, "dos:hidden", NOFOLLOW_LINKS), false);
} catch (UnsupportedOperationException | IllegalMountPointException e) {
return false;
}
}
}